diff --git a/.DS_Store b/.DS_Store
index 5be8bff..748a05e 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index 4c23275..86f3e78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,41 @@ server
# DO NOT REMOVE THESE FOR SECURITY
src/config.js
-.env
\ No newline at end of file
+.env
+
+# web
+
+# dependencies
+web/node_modules
+web/.pnp
+web/.pnp.js
+
+# testing
+web/coverage
+
+# next.js
+web/.next/
+web/out/
+
+# production
+web/build
+
+# misc
+web/.DS_Store
+web/*.pem
+
+# debug
+web/npm-debug.log*
+web/yarn-debug.log*
+web/yarn-error.log*
+
+# local env files
+web/.env*.local
+web/.env
+
+# vercel
+web/.vercel
+
+# typescript
+web/*.tsbuildinfo
+web/next-env.d.ts
diff --git a/web/.DS_Store b/web/.DS_Store
new file mode 100644
index 0000000..0db436e
Binary files /dev/null and b/web/.DS_Store differ
diff --git a/web/.env.example b/web/.env.example
new file mode 100644
index 0000000..10c5448
--- /dev/null
+++ b/web/.env.example
@@ -0,0 +1,14 @@
+# Copy from .env.local on the Vercel dashboard
+# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
+POSTGRES_URL=
+POSTGRES_PRISMA_URL=
+POSTGRES_URL_NON_POOLING=
+POSTGRES_USER=
+POSTGRES_HOST=
+POSTGRES_PASSWORD=
+POSTGRES_DATABASE=
+MONGODB_URI=
+
+# `openssl rand -base64 32`
+AUTH_SECRET=
+AUTH_URL=http://localhost:3000/api/auth
\ No newline at end of file
diff --git a/web/.eslintrc.json b/web/.eslintrc.json
new file mode 100644
index 0000000..bffb357
--- /dev/null
+++ b/web/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
diff --git a/web/.nvmrc b/web/.nvmrc
new file mode 100644
index 0000000..3c03207
--- /dev/null
+++ b/web/.nvmrc
@@ -0,0 +1 @@
+18
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..6a520de
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,5 @@
+## Next.js App Router Course - Starter
+
+This is the starter template for the Next.js App Router Course. It contains the starting code for the dashboard application.
+
+For more information, see the [course curriculum](https://nextjs.org/learn) on the Next.js Website.
diff --git a/web/app/.DS_Store b/web/app/.DS_Store
new file mode 100644
index 0000000..0b1e8d4
Binary files /dev/null and b/web/app/.DS_Store differ
diff --git a/web/app/api/auth/[...nextauth]/route.tsx b/web/app/api/auth/[...nextauth]/route.tsx
new file mode 100644
index 0000000..42e2953
--- /dev/null
+++ b/web/app/api/auth/[...nextauth]/route.tsx
@@ -0,0 +1,2 @@
+import { handlers } from "@/auth"
+export const { GET, POST } = handlers
diff --git a/web/app/api/latest/commandCount/route.tsx b/web/app/api/latest/commandCount/route.tsx
new file mode 100644
index 0000000..c74f597
--- /dev/null
+++ b/web/app/api/latest/commandCount/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, fetchCommandsLength } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const commandCount = await fetchCommandsLength();
+ return NextResponse.json(commandCount, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest command count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/commandTrend/route.tsx b/web/app/api/latest/commandTrend/route.tsx
new file mode 100644
index 0000000..21c83e9
--- /dev/null
+++ b/web/app/api/latest/commandTrend/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, getCommandTrend } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const percent = await getCommandTrend();
+ return NextResponse.json(percent, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest command count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/commands/route.tsx b/web/app/api/latest/commands/route.tsx
new file mode 100644
index 0000000..aeb993d
--- /dev/null
+++ b/web/app/api/latest/commands/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, fetchLatestCommands } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const initialCommands = await fetchLatestCommands();
+ return NextResponse.json(initialCommands, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest commands' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/countData/route.tsx b/web/app/api/latest/countData/route.tsx
new file mode 100644
index 0000000..166384b
--- /dev/null
+++ b/web/app/api/latest/countData/route.tsx
@@ -0,0 +1,33 @@
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, fetchCommandsLength, fetchTotalFishCaught, fetchUserCount, getCommandTrend, getFishTrend, getUserTrend } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const initialCommandCount = await fetchCommandsLength();
+ const initialCommandTrend = await getCommandTrend();
+ const initialUserCount = await fetchUserCount();
+ const initialUserTrend = await getUserTrend();
+ const initialFishCount = await fetchTotalFishCaught();
+ const initialFishTrend = await getFishTrend();
+ return NextResponse.json({ commands: initialCommandCount, commandTrend: initialCommandTrend, users: initialUserCount, userTrend: initialUserTrend, fish: initialFishCount, fishTrend: initialFishTrend }, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest fish count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/fishCount/route.tsx b/web/app/api/latest/fishCount/route.tsx
new file mode 100644
index 0000000..4fda9b2
--- /dev/null
+++ b/web/app/api/latest/fishCount/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, fetchTotalFishCaught } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const fishCount = await fetchTotalFishCaught();
+ return NextResponse.json(fishCount, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest fish count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/fishTrend/route.tsx b/web/app/api/latest/fishTrend/route.tsx
new file mode 100644
index 0000000..5d9f06b
--- /dev/null
+++ b/web/app/api/latest/fishTrend/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, getFishTrend } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const percent = await getFishTrend();
+ return NextResponse.json(percent, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest command count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/userCount/route.tsx b/web/app/api/latest/userCount/route.tsx
new file mode 100644
index 0000000..1be2075
--- /dev/null
+++ b/web/app/api/latest/userCount/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, fetchUserCount } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const userCount = await fetchUserCount();
+ return NextResponse.json(userCount, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest user count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/latest/userTrend/route.tsx b/web/app/api/latest/userTrend/route.tsx
new file mode 100644
index 0000000..c1c9afc
--- /dev/null
+++ b/web/app/api/latest/userTrend/route.tsx
@@ -0,0 +1,29 @@
+// import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { NextApiRequest } from 'next';
+import { NextResponse } from 'next/server';
+import { checkIfUserIsAdmin, getUserTrend } from '@/app/lib/data';
+import { auth } from '@/auth';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const percent = await getUserTrend();
+ return NextResponse.json(percent, { status: 200 });
+
+ } catch (error) {
+ return NextResponse.json(
+ { error: { message: 'Error fetching latest command count' + error } },
+ { status: 500 }
+ );
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/stream/countData/route.tsx b/web/app/api/stream/countData/route.tsx
new file mode 100644
index 0000000..68ee628
--- /dev/null
+++ b/web/app/api/stream/countData/route.tsx
@@ -0,0 +1,78 @@
+import { NextApiRequest } from 'next';
+import { checkIfUserIsAdmin, fetchCommandsLength, fetchTotalFishCaught, fetchUserCount, getCommandTrend, getFishTrend, getUserTrend } from '@/app/lib/data';
+import { setupChangeStream } from '@/lib/dbConnect';
+import { NextResponse } from 'next/server';
+import { auth } from '@/auth';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+export const GET = async (req: NextApiRequest) => {
+ try {
+
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const responseStream = new TransformStream();
+ const writer = responseStream.writable.getWriter();
+ const encoder = new TextEncoder();
+
+ const initialCommandCount = await fetchCommandsLength();
+ const initialCommandTrend = await getCommandTrend();
+ const initialUserCount = await fetchUserCount();
+ const initialUserTrend = await getUserTrend();
+ const initialFishCount = await fetchTotalFishCaught();
+ const initialFishTrend = await getFishTrend();
+ writer.write(encoder.encode(`data: ${JSON.stringify({commands: initialCommandCount, commandTrend: initialCommandTrend, users: initialUserCount, userTrend: initialUserTrend, fish: initialFishCount, fishTrend: initialFishTrend})}\n\n`));
+
+ const pipeline = [
+ {
+ $match: {
+ $and: [{ operationType: "insert" }],
+ },
+ },
+ ];
+
+ const changeStream = await setupChangeStream("commands", pipeline, async (change) => {
+ try {
+ const commands = await fetchCommandsLength();
+ const commandTrend = await getCommandTrend();
+ const users = await fetchUserCount();
+ const userTrend = await getUserTrend();
+ const fish = await fetchTotalFishCaught();
+ const fishTrend = await getFishTrend();
+
+ writer.write(encoder.encode(`data: ${JSON.stringify({commands, commandTrend, users, userTrend, fish, fishTrend})}\n\n`));
+ } catch (writeError) {
+ console.error('Error writing to stream:', writeError);
+ }
+ });
+
+ return new Response(responseStream.readable, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ Connection: 'keep-alive',
+ 'Cache-Control': 'no-cache, no-transform',
+ },
+ });
+
+ } catch (error) {
+ console.error('Error during request:', error);
+ return new Response('An error occurred during request', {
+ status: 500,
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ Connection: 'keep-alive',
+ 'Cache-Control': 'no-cache, no-transform',
+ },
+ });
+ }
+};
\ No newline at end of file
diff --git a/web/app/api/stream/route.tsx b/web/app/api/stream/route.tsx
new file mode 100644
index 0000000..de6a813
--- /dev/null
+++ b/web/app/api/stream/route.tsx
@@ -0,0 +1,81 @@
+import { NextApiRequest } from 'next';
+import { checkIfUserIsAdmin, fetchLatestCommands } from '@/app/lib/data';
+import { setupChangeStream } from '@/lib/dbConnect';
+import { auth } from '@/auth';
+import { NextResponse } from 'next/server';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+export const GET = async (req: NextApiRequest) => {
+
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (!session || !admin) {
+ return NextResponse.json(
+ { error: { message: 'Not authorized' } },
+ { status: 401 }
+ );
+ }
+
+ const { searchParams } = new URL(req.url);
+ const collectionParam = searchParams.get("collection");
+ const collection = collectionParam;
+
+ if (!collection) {
+ return new Response('No collection specified.', {
+ status: 400,
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ Connection: 'keep-alive',
+ 'Cache-Control': 'no-cache, no-transform',
+ },
+ });
+ }
+
+ try {
+ const responseStream = new TransformStream();
+ const writer = responseStream.writable.getWriter();
+ const encoder = new TextEncoder();
+
+ const initialCommands = await fetchLatestCommands();
+ writer.write(encoder.encode(`data: ${JSON.stringify(initialCommands)}\n\n`));
+
+ const pipeline = [
+ {
+ $match: {
+ $and: [{ operationType: "insert" }],
+ },
+ },
+ ];
+
+ await setupChangeStream(collection, pipeline, async (change) => {
+ try {
+ writer.write(encoder.encode(`data: ${JSON.stringify([change.fullDocument])}\n\n`));
+ } catch (writeError) {
+ console.error('Error writing to stream:', writeError);
+ }
+ });
+
+ return new Response(responseStream.readable, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ Connection: 'keep-alive',
+ 'Cache-Control': 'no-cache, no-transform',
+ },
+ });
+
+ } catch (error) {
+ console.error('Error during request:', error);
+ return new Response('An error occurred during request', {
+ status: 500,
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ Connection: 'keep-alive',
+ 'Cache-Control': 'no-cache, no-transform',
+ },
+ });
+ }
+};
\ No newline at end of file
diff --git a/web/app/dashboard/(overview)/loading.tsx b/web/app/dashboard/(overview)/loading.tsx
new file mode 100644
index 0000000..01e6c60
--- /dev/null
+++ b/web/app/dashboard/(overview)/loading.tsx
@@ -0,0 +1,5 @@
+import DashboardSkeleton from '@/app/ui/skeletons';
+
+export default function Loading() {
+ return ;
+}
\ No newline at end of file
diff --git a/web/app/dashboard/(overview)/page.tsx b/web/app/dashboard/(overview)/page.tsx
new file mode 100644
index 0000000..6334170
--- /dev/null
+++ b/web/app/dashboard/(overview)/page.tsx
@@ -0,0 +1,63 @@
+import { lusitana } from '@/app/ui/fonts';
+import CommandsChart from '@/app/ui/dashboard/commands-chart';
+import { Suspense } from 'react';
+import {
+ LatestCommandsSkeleton,
+ ChartSkeleton,
+ CardsSkeleton,
+} from '@/app/ui/skeletons';
+import dynamic from 'next/dynamic';
+import { auth } from '@/auth';
+import { checkIfUserIsAdmin } from '@/app/lib/data';
+
+const LatestCommands = dynamic(
+ () => import('@/app/ui/dashboard/latest-commands'),
+ { ssr: false },
+);
+
+const CardWrapper = dynamic(
+ () => import('@/app/ui/dashboard/cards'), {
+ ssr: false,
+});
+
+export default async function Page() {
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (session && admin) {
+ return (
+
+
+ Dashboard
+
+
+ }>
+
+
+
+
+ }>
+
+
+ }>
+
+
+
+
+ );
+ } else if (session) {
+ return (
+
+
You need to be an admin to view this page
+
+ );
+ } else {
+ return (
+
+
You need to be logged in to view this page
+
+ );
+ }
+}
+
diff --git a/web/app/dashboard/commands/[id]/page.tsx b/web/app/dashboard/commands/[id]/page.tsx
new file mode 100644
index 0000000..cb30ec1
--- /dev/null
+++ b/web/app/dashboard/commands/[id]/page.tsx
@@ -0,0 +1,88 @@
+import Breadcrumbs from '@/app/ui/commands/breadcrumbs';
+import { fetchCommandById, fetchInteractionById } from '@/app/lib/data';
+import { auth } from '@/auth';
+import { checkIfUserIsAdmin } from '@/app/lib/data';
+
+export default async function Page({ params }) {
+ const id = params.id;
+ const command = await fetchCommandById(id);
+ const interaction = await fetchInteractionById(command.chainedTo);
+ const interactions = interaction.interactions;
+
+ const session = await auth();
+ const userId = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(userId);
+
+ if (session && admin) {
+
+ return (
+
+
+ Interaction Steps for Command ID: {id}
+
+ {command.options && (
+ <>
+ Arguments:
+
+ {command.options.map((option) => (
+ -
+ {option.name}: {option.value}
+
+ ))}
+
+
+ >
+ )}
+
+ {interactions.map((interaction) => (
+ -
+
+ Command: {interaction.command}
+
+ Channel: {interaction.channel}
+
+ Guild: {interaction.guild}
+
+ Time: {new Date(interaction.time).toLocaleString()}
+
+ Type: {interaction.type}
+
+
+
+
+ ))}
+
+ {interaction && (
+ <>
+
+
Final Interaction Status
+
Status: {interaction.status}
+
Message: {interaction.statusMessage}
+
>
+ )}
+
+ );
+ } else if (session) {
+ return (
+
+
You need to be an admin to view this page
+
+ );
+ }
+ else {
+ return (
+
+
You need to be logged in to view this page
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/web/app/dashboard/commands/page.tsx b/web/app/dashboard/commands/page.tsx
new file mode 100644
index 0000000..9eb6046
--- /dev/null
+++ b/web/app/dashboard/commands/page.tsx
@@ -0,0 +1,59 @@
+import Pagination from '@/app/ui/commands/pagination';
+import Search from '@/app/ui/search';
+import Table from '@/app/ui/commands/table';
+// import { CreateInvoice } from '@/app/ui/commands/buttons';
+import { fetchCommandsPages } from '@/app/lib/data';
+import { lusitana } from '@/app/ui/fonts';
+import { CommandsTableSkeleton } from '@/app/ui/skeletons';
+import { Suspense } from 'react';
+import { auth } from '@/auth';
+import { checkIfUserIsAdmin } from '@/app/lib/data';
+
+export default async function Page({
+ searchParams,
+}: {
+ searchParams?: {
+ query?: string;
+ page?: string;
+ };
+}) {
+ const query = searchParams?.query || '';
+ const currentPage = Number(searchParams?.page) || 1;
+ const totalPages = await fetchCommandsPages(query);
+
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (session && admin) {
+ return (
+
+
+
Commands
+
+
+
+ {/* */}
+
+
}>
+
+
+
+
+ );
+ } else if (session) {
+ return (
+
+
You need to be an admin to view this page
+
+ );
+ } else {
+ return (
+
+
You need to be logged in to view this page
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/web/app/dashboard/layout.tsx b/web/app/dashboard/layout.tsx
new file mode 100644
index 0000000..05f9e35
--- /dev/null
+++ b/web/app/dashboard/layout.tsx
@@ -0,0 +1,19 @@
+import SideNav from '@/app/ui/dashboard/sidenav';
+import TopNav from '@/app/ui/dashboard/topnav';
+import { Providers } from '../providers';
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/web/app/dashboard/users/page.tsx b/web/app/dashboard/users/page.tsx
new file mode 100644
index 0000000..fd78740
--- /dev/null
+++ b/web/app/dashboard/users/page.tsx
@@ -0,0 +1,59 @@
+import Pagination from '@/app/ui/commands/pagination';
+import Search from '@/app/ui/search';
+import Table from '@/app/ui/users/table';
+// import { CreateUser } from '@/app/ui/users/buttons';
+import { fetchUsersPages } from '@/app/lib/data';
+import { lusitana } from '@/app/ui/fonts';
+import { CommandsTableSkeleton } from '@/app/ui/skeletons';
+import { Suspense } from 'react';
+import { auth } from '@/auth';
+import { checkIfUserIsAdmin } from '@/app/lib/data';
+
+export default async function Page({
+ searchParams,
+}: {
+ searchParams?: {
+ query?: string;
+ page?: string;
+ };
+}) {
+ const query = searchParams?.query || '';
+ const currentPage = Number(searchParams?.page) || 1;
+ const totalPages = await fetchUsersPages(query);
+
+ const session = await auth();
+ const id = session?.user?.image?.split('/')[4]?.split('.')[0] ?? '';
+ const admin = await checkIfUserIsAdmin(id);
+
+ if (session && admin) {
+ return (
+
+
+
Users
+
+
+
+ {/* */}
+
+
}>
+
+
+
+
+ );
+ } else if (session) {
+ return (
+
+
You need to be an admin to view this page
+
+ );
+ } else {
+ return (
+
+
You need to be logged in to view this page
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
new file mode 100644
index 0000000..02b5ce2
--- /dev/null
+++ b/web/app/layout.tsx
@@ -0,0 +1,17 @@
+import '@/app/ui/global.css';
+import { inter } from '@/app/ui/fonts';
+import { Providers } from './providers';
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/app/lib/data.ts b/web/app/lib/data.ts
new file mode 100644
index 0000000..152489e
--- /dev/null
+++ b/web/app/lib/data.ts
@@ -0,0 +1,233 @@
+import dbConnect, { setupChangeStream } from '@/lib/dbConnect';
+import { ICommand, Command } from '@/app/models/CommandModel';
+import { User } from '@/app/models/UserModel';
+import { FishData } from '@/app/models/FishModel';
+import { unstable_noStore as noStore } from 'next/cache';
+import { Interaction } from '../models/InteractionModel';
+
+async function sortCommandsByRecency(commands: ICommand[]): Promise {
+ noStore();
+ return commands.sort((a: ICommand, b: ICommand) => {
+ return new Date(b.time).getTime() - new Date(a.time).getTime();
+ });
+}
+
+export async function getUserTrend() {
+ noStore();
+ await dbConnect();
+
+ const [thisMonthCount, lastMonthCount] = await Promise.all([
+ User.countDocuments({
+ createdAt: {
+ $gte: new Date().setDate(1),
+ $lte: new Date().setMonth(new Date().getMonth() + 1, 0),
+ },
+ }),
+ User.countDocuments({
+ createdAt: {
+ $gte: new Date().setMonth(new Date().getMonth() - 1, 1),
+ $lte: new Date().setDate(0),
+ },
+ }),
+ ]);
+
+ const percentChange = ((thisMonthCount - lastMonthCount) / lastMonthCount) * 100;
+ const daysInLastMonth = new Date().getDate();
+ const averageGrowthRate = (thisMonthCount - lastMonthCount) / daysInLastMonth;
+ const projectedGrowth = averageGrowthRate * daysInLastMonth;
+ const projectedPercentChange = (projectedGrowth / lastMonthCount) * 100;
+
+ return Math.max(Math.min(Math.round(projectedPercentChange), 200), -200);
+}
+
+export async function getFishTrend() {
+ noStore();
+ await dbConnect();
+
+ const [thisMonthCount, lastMonthCount] = await Promise.all([
+ FishData.countDocuments({
+ obtained: {
+ $gte: new Date().setDate(1),
+ $lte: new Date().setMonth(new Date().getMonth() + 1, 0),
+ },
+ }),
+ FishData.countDocuments({
+ obtained: {
+ $gte: new Date().setMonth(new Date().getMonth() - 1, 1),
+ $lte: new Date().setDate(0),
+ },
+ }),
+ ]);
+
+ const percentChange = ((thisMonthCount - lastMonthCount) / lastMonthCount) * 100;
+ const daysInLastMonth = new Date().getDate();
+ const averageGrowthRate = (thisMonthCount - lastMonthCount) / daysInLastMonth;
+ const projectedGrowth = averageGrowthRate * daysInLastMonth;
+ const projectedPercentChange = (projectedGrowth / lastMonthCount) * 100;
+
+ return Math.max(Math.min(Math.round(projectedPercentChange), 200), -200);
+}
+
+export async function getCommandTrend() {
+ noStore();
+ await dbConnect();
+
+ const [thisMonthCount, lastMonthCount] = await Promise.all([
+ Command.countDocuments({
+ time: {
+ $gte: new Date().setDate(1),
+ $lte: new Date().setMonth(new Date().getMonth() + 1, 0),
+ },
+ }),
+ Command.countDocuments({
+ time: {
+ $gte: new Date().setMonth(new Date().getMonth() - 1, 1),
+ $lte: new Date().setDate(0),
+ },
+ }),
+ ]);
+
+ const percentChange = ((thisMonthCount - lastMonthCount) / lastMonthCount) * 100;
+ const daysInLastMonth = new Date().getDate();
+ const averageGrowthRate = (thisMonthCount - lastMonthCount) / daysInLastMonth;
+ const projectedGrowth = averageGrowthRate * daysInLastMonth;
+ const projectedPercentChange = (projectedGrowth / lastMonthCount) * 100;
+
+ return Math.max(Math.min(Math.round(projectedPercentChange), 200), -200);
+}
+
+export async function checkIfUserIsAdmin(userId: string) {
+ noStore();
+ await dbConnect();
+ const user = await User.findOne({ userId });
+ return user?.isAdmin;
+}
+
+export async function getUserById(userId: string) {
+ noStore();
+ await dbConnect();
+ return await User.findOne({ userId });
+}
+
+export async function fetchLatestCommands() {
+ noStore();
+
+ return new Promise((resolve, reject) => {
+ let commands: ICommand[] = [];
+
+ // Initial fetch of commands
+ const findCommands = async () => {
+ const result = await Command.find({});
+ commands = result.map((doc: any) => JSON.parse(JSON.stringify(doc)));
+ resolve((await sortCommandsByRecency(commands)).slice(0, 10)); // Return the latest 10 commands
+ };
+
+ findCommands().catch(reject);
+ });
+}
+
+export async function fetchCommands() {
+ noStore();
+ await dbConnect();
+
+ /* find all the data in our database */
+ const result = await Command.find({});
+
+ /* Ensures all objectIds and nested objectIds are serialized as JSON data */
+ let commands = result.map((doc: any) => {
+ const command = JSON.parse(JSON.stringify(doc));
+ return command;
+ });
+
+ // Sort commands by most recent time field
+ commands = await sortCommandsByRecency(commands);
+
+ return commands;
+}
+
+export async function fetchCommandsLength() {
+ noStore();
+ await dbConnect();
+ return await Command.countDocuments({});
+}
+
+export async function fetchUserCount() {
+ noStore();
+ await dbConnect();
+ return await User.countDocuments({});
+}
+
+export async function fetchTotalFishCaught() {
+ noStore();
+ await dbConnect();
+ let fishData = await FishData.find({});
+ fishData = fishData.map((doc: any) => {
+ const fish = JSON.parse(JSON.stringify(doc));
+ return fish.count;
+ });
+ return fishData.reduce((a: number, b: number) => a + b, 0);
+}
+
+export async function fetchFilteredCommands(query: string, currentPage: number) {
+ noStore();
+ await dbConnect();
+ const limit = 10;
+ const skip = (currentPage - 1) * limit;
+ const regex = new RegExp(query, 'i');
+ const commands = await Command.find({
+ $or: [{ user: regex }, { command: regex }],
+ })
+ .sort({ time: -1 })
+ .skip(skip)
+ .limit(limit);
+ return commands;
+}
+
+export async function fetchCommandsPages(query: string) {
+ noStore();
+ await dbConnect();
+ const limit = 10;
+ const count = await Command.countDocuments({
+ $or: [{ user: new RegExp(query, 'i') }, { command: new RegExp(query, 'i') }],
+ });
+ return Math.ceil(count / limit);
+}
+
+export async function fetchFilteredUsers(query: string, currentPage: number) {
+ noStore();
+ await dbConnect();
+ const limit = 10;
+ const skip = (currentPage - 1) * limit;
+ const regex = new RegExp(query, 'i');
+ const users = await User.find({
+ userId: regex,
+ })
+ .sort({ xp: -1 })
+ .skip(skip)
+ .limit(limit);
+ return users;
+}
+
+export async function fetchUsersPages(query: string) {
+ noStore();
+ await dbConnect();
+ const limit = 10;
+ const count = await User.countDocuments({
+ userId: new RegExp(query, 'i'),
+ });
+ return Math.ceil(count / limit);
+}
+
+export async function fetchCommandById(id: string) {
+ noStore();
+ await dbConnect();
+ const command = await Command.findById(id);
+ return command;
+}
+
+export async function fetchInteractionById(id: string) {
+ noStore();
+ await dbConnect();
+ const interaction = await Interaction.findById(id);
+ return interaction;
+}
\ No newline at end of file
diff --git a/web/app/lib/definitions.ts b/web/app/lib/definitions.ts
new file mode 100644
index 0000000..b1a4fbf
--- /dev/null
+++ b/web/app/lib/definitions.ts
@@ -0,0 +1,88 @@
+// This file contains type definitions for your data.
+// It describes the shape of the data, and what data type each property should accept.
+// For simplicity of teaching, we're manually defining these types.
+// However, these types are generated automatically if you're using an ORM such as Prisma.
+export type User = {
+ id: string;
+ name: string;
+ email: string;
+ password: string;
+};
+
+export type Customer = {
+ id: string;
+ name: string;
+ email: string;
+ image_url: string;
+};
+
+export type Invoice = {
+ id: string;
+ customer_id: string;
+ amount: number;
+ date: string;
+ // In TypeScript, this is called a string union type.
+ // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'.
+ status: 'pending' | 'paid';
+};
+
+export type Revenue = {
+ month: string;
+ revenue: number;
+};
+
+export type LatestInvoice = {
+ id: string;
+ name: string;
+ image_url: string;
+ email: string;
+ amount: string;
+};
+
+// The database returns a number for amount, but we later format it to a string with the formatCurrency function
+export type LatestInvoiceRaw = Omit & {
+ amount: number;
+};
+
+export type InvoicesTable = {
+ id: string;
+ customer_id: string;
+ name: string;
+ email: string;
+ image_url: string;
+ date: string;
+ amount: number;
+ status: 'pending' | 'paid';
+};
+
+export type CustomersTableType = {
+ id: string;
+ name: string;
+ email: string;
+ image_url: string;
+ total_invoices: number;
+ total_pending: number;
+ total_paid: number;
+};
+
+export type FormattedCustomersTable = {
+ id: string;
+ name: string;
+ email: string;
+ image_url: string;
+ total_invoices: number;
+ total_pending: string;
+ total_paid: string;
+};
+
+export type CustomerField = {
+ id: string;
+ name: string;
+};
+
+export type InvoiceForm = {
+ id: string;
+ customer_id: string;
+ amount: number;
+ status: 'pending' | 'paid';
+};
diff --git a/web/app/lib/placeholder-data.js b/web/app/lib/placeholder-data.js
new file mode 100644
index 0000000..15a4156
--- /dev/null
+++ b/web/app/lib/placeholder-data.js
@@ -0,0 +1,188 @@
+// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
+// https://nextjs.org/learn/dashboard-app/fetching-data
+const users = [
+ {
+ id: '410544b2-4001-4271-9855-fec4b6a6442a',
+ name: 'User',
+ email: 'user@nextmail.com',
+ password: '123456',
+ },
+];
+
+const customers = [
+ {
+ id: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
+ name: 'Delba de Oliveira',
+ email: 'delba@oliveira.com',
+ image_url: '/customers/delba-de-oliveira.png',
+ },
+ {
+ id: '3958dc9e-742f-4377-85e9-fec4b6a6442a',
+ name: 'Lee Robinson',
+ email: 'lee@robinson.com',
+ image_url: '/customers/lee-robinson.png',
+ },
+ {
+ id: '3958dc9e-737f-4377-85e9-fec4b6a6442a',
+ name: 'Hector Simpson',
+ email: 'hector@simpson.com',
+ image_url: '/customers/hector-simpson.png',
+ },
+ {
+ id: '50ca3e18-62cd-11ee-8c99-0242ac120002',
+ name: 'Steven Tey',
+ email: 'steven@tey.com',
+ image_url: '/customers/steven-tey.png',
+ },
+ {
+ id: '3958dc9e-787f-4377-85e9-fec4b6a6442a',
+ name: 'Steph Dietz',
+ email: 'steph@dietz.com',
+ image_url: '/customers/steph-dietz.png',
+ },
+ {
+ id: '76d65c26-f784-44a2-ac19-586678f7c2f2',
+ name: 'Michael Novotny',
+ email: 'michael@novotny.com',
+ image_url: '/customers/michael-novotny.png',
+ },
+ {
+ id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa',
+ name: 'Evil Rabbit',
+ email: 'evil@rabbit.com',
+ image_url: '/customers/evil-rabbit.png',
+ },
+ {
+ id: '126eed9c-c90c-4ef6-a4a8-fcf7408d3c66',
+ name: 'Emil Kowalski',
+ email: 'emil@kowalski.com',
+ image_url: '/customers/emil-kowalski.png',
+ },
+ {
+ id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9',
+ name: 'Amy Burns',
+ email: 'amy@burns.com',
+ image_url: '/customers/amy-burns.png',
+ },
+ {
+ id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB',
+ name: 'Balazs Orban',
+ email: 'balazs@orban.com',
+ image_url: '/customers/balazs-orban.png',
+ },
+];
+
+const invoices = [
+ {
+ customer_id: customers[0].id,
+ amount: 15795,
+ status: 'pending',
+ date: '2022-12-06',
+ },
+ {
+ customer_id: customers[1].id,
+ amount: 20348,
+ status: 'pending',
+ date: '2022-11-14',
+ },
+ {
+ customer_id: customers[4].id,
+ amount: 3040,
+ status: 'paid',
+ date: '2022-10-29',
+ },
+ {
+ customer_id: customers[3].id,
+ amount: 44800,
+ status: 'paid',
+ date: '2023-09-10',
+ },
+ {
+ customer_id: customers[5].id,
+ amount: 34577,
+ status: 'pending',
+ date: '2023-08-05',
+ },
+ {
+ customer_id: customers[7].id,
+ amount: 54246,
+ status: 'pending',
+ date: '2023-07-16',
+ },
+ {
+ customer_id: customers[6].id,
+ amount: 666,
+ status: 'pending',
+ date: '2023-06-27',
+ },
+ {
+ customer_id: customers[3].id,
+ amount: 32545,
+ status: 'paid',
+ date: '2023-06-09',
+ },
+ {
+ customer_id: customers[4].id,
+ amount: 1250,
+ status: 'paid',
+ date: '2023-06-17',
+ },
+ {
+ customer_id: customers[5].id,
+ amount: 8546,
+ status: 'paid',
+ date: '2023-06-07',
+ },
+ {
+ customer_id: customers[1].id,
+ amount: 500,
+ status: 'paid',
+ date: '2023-08-19',
+ },
+ {
+ customer_id: customers[5].id,
+ amount: 8945,
+ status: 'paid',
+ date: '2023-06-03',
+ },
+ {
+ customer_id: customers[2].id,
+ amount: 8945,
+ status: 'paid',
+ date: '2023-06-18',
+ },
+ {
+ customer_id: customers[0].id,
+ amount: 8945,
+ status: 'paid',
+ date: '2023-10-04',
+ },
+ {
+ customer_id: customers[2].id,
+ amount: 1000,
+ status: 'paid',
+ date: '2022-06-05',
+ },
+];
+
+const revenue = [
+ { month: 'Jan', revenue: 2000 },
+ { month: 'Feb', revenue: 1800 },
+ { month: 'Mar', revenue: 2200 },
+ { month: 'Apr', revenue: 2500 },
+ { month: 'May', revenue: 2300 },
+ { month: 'Jun', revenue: 3200 },
+ { month: 'Jul', revenue: 3500 },
+ { month: 'Aug', revenue: 3700 },
+ { month: 'Sep', revenue: 2500 },
+ { month: 'Oct', revenue: 2800 },
+ { month: 'Nov', revenue: 3000 },
+ { month: 'Dec', revenue: 4800 },
+];
+
+module.exports = {
+ users,
+ customers,
+ invoices,
+ revenue,
+};
diff --git a/web/app/lib/utils.ts b/web/app/lib/utils.ts
new file mode 100644
index 0000000..2d185d1
--- /dev/null
+++ b/web/app/lib/utils.ts
@@ -0,0 +1,69 @@
+import { Revenue } from './definitions';
+
+export const formatCurrency = (amount: number) => {
+ return (amount / 100).toLocaleString('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ });
+};
+
+export const formatDateToLocal = (
+ dateStr: string,
+ locale: string = 'en-US',
+) => {
+ const date = new Date(dateStr);
+ const options: Intl.DateTimeFormatOptions = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ const formatter = new Intl.DateTimeFormat(locale, options);
+ return formatter.format(date);
+};
+
+export const generateYAxis = (commands: { month: string; count: number }[]) => {
+ // Calculate what labels we need to display on the y-axis
+ // based on highest record and in 1000s
+ const yAxisLabels = [];
+ const highestRecord = Math.max(...commands.map((month) => month.count));
+ const topLabel = Math.ceil(highestRecord / 1000) * 1000;
+
+ for (let i = topLabel; i >= 0; i -= 1000) {
+ yAxisLabels.push(`${i / 1000}K`);
+ }
+
+ return { yAxisLabels, topLabel };
+};
+
+export const generatePagination = (currentPage: number, totalPages: number) => {
+ // If the total number of pages is 7 or less,
+ // display all pages without any ellipsis.
+ if (totalPages <= 7) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ // If the current page is among the first 3 pages,
+ // show the first 3, an ellipsis, and the last 2 pages.
+ if (currentPage <= 3) {
+ return [1, 2, 3, '...', totalPages - 1, totalPages];
+ }
+
+ // If the current page is among the last 3 pages,
+ // show the first 2, an ellipsis, and the last 3 pages.
+ if (currentPage >= totalPages - 2) {
+ return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages];
+ }
+
+ // If the current page is somewhere in the middle,
+ // show the first page, an ellipsis, the current page and its neighbors,
+ // another ellipsis, and the last page.
+ return [
+ 1,
+ '...',
+ currentPage - 1,
+ currentPage,
+ currentPage + 1,
+ '...',
+ totalPages,
+ ];
+};
diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx
new file mode 100644
index 0000000..33b7d21
--- /dev/null
+++ b/web/app/login/page.tsx
@@ -0,0 +1,17 @@
+import LoginForm from '@/app/ui/login-form';
+import FishingRPGLogo from '../ui/logo';
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/app/models/BaitModel.ts b/web/app/models/BaitModel.ts
new file mode 100644
index 0000000..3e2e8ab
--- /dev/null
+++ b/web/app/models/BaitModel.ts
@@ -0,0 +1,107 @@
+import { Schema, Document, model, Model } from 'mongoose';
+import { Item, ItemData } from './ItemModel';
+
+interface IBait extends Document {
+ capabilities: string[];
+ biomes: string[];
+ multiplier: number;
+ requirements: {
+ level: number;
+ };
+ obtained?: number;
+ fishCaught: number;
+ count: number;
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ weights: {
+ common: number;
+ uncommon: number;
+ rare: number;
+ ultra: number;
+ giant: number;
+ legendary: number;
+ lucky: number;
+ };
+ type: string;
+}
+
+const baitSchema = new Schema({
+ capabilities: {
+ type: [String],
+ },
+ biomes: {
+ type: [String],
+ },
+ multiplier: {
+ type: Number,
+ default: 1,
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ },
+ obtained: {
+ type: Number,
+ },
+ fishCaught: {
+ type: Number,
+ default: 0,
+ },
+ count: {
+ type: Number,
+ default: 1,
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+ weights: {
+ common: {
+ type: Number,
+ default: 7000,
+ },
+ uncommon: {
+ type: Number,
+ default: 2500,
+ },
+ rare: {
+ type: Number,
+ default: 500,
+ },
+ ultra: {
+ type: Number,
+ default: 100,
+ },
+ giant: {
+ type: Number,
+ default: 50,
+ },
+ legendary: {
+ type: Number,
+ default: 20,
+ },
+ lucky: {
+ type: Number,
+ default: 1,
+ },
+ },
+ type: {
+ type: String,
+ default: 'bait',
+ },
+});
+
+const Bait: Model = Item.discriminator('Bait', baitSchema);
+const BaitData: Model = ItemData.discriminator('BaitData', baitSchema);
+
+export { Bait, BaitData };
diff --git a/web/app/models/BiomeModel.ts b/web/app/models/BiomeModel.ts
new file mode 100644
index 0000000..59aa2f0
--- /dev/null
+++ b/web/app/models/BiomeModel.ts
@@ -0,0 +1,38 @@
+import { Schema, model, Document } from 'mongoose';
+
+interface IBiome extends Document {
+ name: string;
+ requirements?: string[];
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ type: string;
+}
+
+const biomeSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ },
+ requirements: {
+ type: [String],
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'rawfish:1209352519726276648',
+ },
+ },
+ type: {
+ type: String,
+ default: 'biome',
+ },
+});
+
+const Biome = model('Biome', biomeSchema);
+export { Biome };
diff --git a/web/app/models/BuffModel.ts b/web/app/models/BuffModel.ts
new file mode 100644
index 0000000..7d06752
--- /dev/null
+++ b/web/app/models/BuffModel.ts
@@ -0,0 +1,85 @@
+import { Schema, model, Document } from 'mongoose';
+import { Item, ItemData } from './ItemModel';
+
+interface IBuff extends Document {
+ name: string;
+ description?: string;
+ rarity: string;
+ active: boolean;
+ capabilities?: string[];
+ requirements: {
+ level: number;
+ };
+ obtained?: number;
+ length?: number;
+ endTime?: number;
+ count: number;
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ user?: string;
+ type: string;
+}
+
+const buffSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ },
+ rarity: {
+ type: String,
+ default: 'Common',
+ },
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ capabilities: {
+ type: [String],
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ },
+ obtained: {
+ type: Number,
+ },
+ length: {
+ type: Number,
+ },
+ endTime: {
+ type: Number,
+ },
+ count: {
+ type: Number,
+ default: 1,
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+ user: {
+ type: String,
+ },
+ type: {
+ type: String,
+ default: 'buff',
+ },
+});
+
+const Buff = Item.discriminator('Buff', buffSchema);
+const BuffData = ItemData.discriminator('BuffData', buffSchema);
+
+export { Buff, BuffData };
diff --git a/web/app/models/CodeModel.ts b/web/app/models/CodeModel.ts
new file mode 100644
index 0000000..df58e0b
--- /dev/null
+++ b/web/app/models/CodeModel.ts
@@ -0,0 +1,52 @@
+import { Schema, model, Document, Types } from 'mongoose';
+
+interface ICode extends Document {
+ code: string;
+ uses: number;
+ usesLeft: number;
+ type: string;
+ money?: number;
+ items?: Types.ObjectId[];
+ createdAt: Date;
+ expiresAt: Date;
+}
+
+const CodeSchema = new Schema({
+ code: {
+ type: String,
+ required: true,
+ },
+ uses: {
+ type: Number,
+ required: true,
+ default: 1,
+ },
+ usesLeft: {
+ type: Number,
+ required: true,
+ default: 1,
+ },
+ type: {
+ type: String,
+ required: true,
+ default: 'code',
+ },
+ money: {
+ type: Number,
+ default: 0,
+ },
+ items: [{
+ type: Schema.Types.ObjectId,
+ }],
+ createdAt: {
+ type: Date,
+ default: Date.now,
+ },
+ expiresAt: {
+ type: Date,
+ default: Date.now,
+ },
+});
+
+const Code = model('Code', CodeSchema);
+export { Code };
diff --git a/web/app/models/CommandModel.ts b/web/app/models/CommandModel.ts
new file mode 100644
index 0000000..1e85217
--- /dev/null
+++ b/web/app/models/CommandModel.ts
@@ -0,0 +1,52 @@
+import mongoose from 'mongoose';
+import { Schema, model, Document, Types } from 'mongoose';
+
+interface ICommand extends Document {
+ user: string;
+ command: string;
+ options?: object;
+ time: Date;
+ channel?: string;
+ guild?: string;
+ interaction?: object;
+ chainedTo?: Types.ObjectId;
+ type: string;
+}
+
+const commandSchema = new Schema({
+ user: {
+ type: String,
+ required: true,
+ },
+ command: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Object,
+ },
+ time: {
+ type: Date,
+ default: Date.now,
+ },
+ channel: {
+ type: String,
+ },
+ guild: {
+ type: String,
+ },
+ interaction: {
+ type: Object,
+ },
+ chainedTo: {
+ type: Schema.Types.ObjectId,
+ ref: 'Interaction',
+ },
+ type: {
+ type: String,
+ default: 'command',
+ },
+});
+
+const Command = mongoose.models.Command || model('Command', commandSchema);
+export { Command }; export type { ICommand };
diff --git a/web/app/models/CustomRodModel.ts b/web/app/models/CustomRodModel.ts
new file mode 100644
index 0000000..cdd0c37
--- /dev/null
+++ b/web/app/models/CustomRodModel.ts
@@ -0,0 +1,150 @@
+import { Schema, Document, model, Model } from 'mongoose';
+import { Item, ItemData } from './ItemModel';
+
+interface ICustomRod extends Document {
+ type: string;
+ rod: typeof ItemData['_id'];
+ reel: typeof ItemData['_id'];
+ hook: typeof ItemData['_id'];
+ handle: typeof ItemData['_id'];
+ capabilities: string[];
+ requirements: {
+ level: number;
+ };
+ obtained: Date;
+ fishCaught: number;
+ state: 'mint' | 'repaired' | 'broken' | 'destroyed';
+ durability: number;
+ maxDurability: number;
+ repairs: number;
+ maxRepairs: number;
+ repairCost: number;
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ weights: {
+ common: number;
+ uncommon: number;
+ rare: number;
+ ultra: number;
+ giant: number;
+ legendary: number;
+ lucky: number;
+ };
+}
+
+const CustomRodSchema: Schema = new Schema({
+ type: {
+ type: String,
+ default: 'customrod',
+ },
+ rod: {
+ type: Schema.Types.ObjectId,
+ ref: 'ItemData',
+ required: true,
+ },
+ reel: {
+ type: Schema.Types.ObjectId,
+ ref: 'ItemData',
+ required: true,
+ },
+ hook: {
+ type: Schema.Types.ObjectId,
+ ref: 'ItemData',
+ required: true,
+ },
+ handle: {
+ type: Schema.Types.ObjectId,
+ ref: 'ItemData',
+ required: true,
+ },
+ capabilities: {
+ type: [String],
+ default: [],
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ },
+ obtained: {
+ type: Date,
+ default: Date.now,
+ },
+ fishCaught: {
+ type: Number,
+ default: 0,
+ },
+ state: {
+ type: String,
+ enum: ['mint', 'repaired', 'broken', 'destroyed'],
+ default: 'mint',
+ },
+ durability: {
+ type: Number,
+ default: 1000,
+ },
+ maxDurability: {
+ type: Number,
+ default: 1000,
+ },
+ repairs: {
+ type: Number,
+ default: 0,
+ },
+ maxRepairs: {
+ type: Number,
+ default: 3,
+ },
+ repairCost: {
+ type: Number,
+ default: 1000,
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+ weights: {
+ common: {
+ type: Number,
+ default: 7000,
+ },
+ uncommon: {
+ type: Number,
+ default: 2500,
+ },
+ rare: {
+ type: Number,
+ default: 500,
+ },
+ ultra: {
+ type: Number,
+ default: 100,
+ },
+ giant: {
+ type: Number,
+ default: 50,
+ },
+ legendary: {
+ type: Number,
+ default: 20,
+ },
+ lucky: {
+ type: Number,
+ default: 1,
+ },
+ },
+});
+
+const CustomRod: Model = model('CustomRod', CustomRodSchema);
+const CustomRodData: Model = model('CustomRodData', CustomRodSchema);
+
+export { CustomRod, CustomRodData };
diff --git a/web/app/models/FishModel.ts b/web/app/models/FishModel.ts
new file mode 100644
index 0000000..da35fde
--- /dev/null
+++ b/web/app/models/FishModel.ts
@@ -0,0 +1,80 @@
+import { model, Schema, Document } from 'mongoose';
+import mongoose from 'mongoose';
+
+interface IFish extends Document {
+ name: string;
+ description: string;
+ rarity: string;
+ value: number;
+ locked: boolean;
+ qualities: string[];
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ user: string;
+ obtained: number;
+ count: number;
+ type: string;
+ biome: string;
+}
+
+const FishSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ default: 'A fish',
+ },
+ rarity: {
+ type: String,
+ default: 'Common',
+ },
+ value: {
+ type: Number,
+ default: 10,
+ },
+ locked: {
+ type: Boolean,
+ default: false,
+ },
+ qualities: [{
+ type: String,
+ default: 'weak',
+ }],
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'rawfish:1209352519726276648',
+ },
+ },
+ user: {
+ type: String,
+ },
+ obtained: {
+ type: Number,
+ },
+ count: {
+ type: Number,
+ default: 1,
+ },
+ type: {
+ type: String,
+ default: 'fish',
+ },
+ biome: {
+ type: String,
+ default: 'ocean',
+ },
+});
+
+const Fish = mongoose.models.Fish || model('Fish', FishSchema);
+const FishData = mongoose.models.FishData || model('FishData', FishSchema);
+export { Fish, FishData };
+export type { IFish };
diff --git a/web/app/models/GachaModel.ts b/web/app/models/GachaModel.ts
new file mode 100644
index 0000000..b91d891
--- /dev/null
+++ b/web/app/models/GachaModel.ts
@@ -0,0 +1,121 @@
+import { Schema, model, Document } from 'mongoose';
+import { Item, ItemData } from './ItemModel';
+
+interface Gacha extends Document {
+ name: string;
+ description?: string;
+ rarity: string;
+ opened: boolean;
+ capabilities?: string[];
+ items: number;
+ requirements: {
+ level: number;
+ };
+ obtained?: number;
+ count: number;
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ weights: {
+ common: number;
+ uncommon: number;
+ rare: number;
+ ultra: number;
+ giant: number;
+ legendary: number;
+ lucky: number;
+ };
+ user?: string;
+ type: string;
+}
+
+const gachaSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ },
+ rarity: {
+ type: String,
+ default: 'Common',
+ },
+ opened: {
+ type: Boolean,
+ default: false,
+ },
+ capabilities: {
+ type: [String],
+ },
+ items: {
+ type: Number,
+ default: 1,
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ },
+ obtained: {
+ type: Number,
+ },
+ count: {
+ type: Number,
+ default: 1,
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+ weights: {
+ common: {
+ type: Number,
+ default: 7000,
+ },
+ uncommon: {
+ type: Number,
+ default: 2500,
+ },
+ rare: {
+ type: Number,
+ default: 500,
+ },
+ ultra: {
+ type: Number,
+ default: 100,
+ },
+ giant: {
+ type: Number,
+ default: 50,
+ },
+ legendary: {
+ type: Number,
+ default: 20,
+ },
+ lucky: {
+ type: Number,
+ default: 1,
+ },
+ },
+ user: {
+ type: String,
+ },
+ type: {
+ type: String,
+ default: 'gacha',
+ },
+});
+
+const Gacha = Item.discriminator('Gacha', gachaSchema);
+const GachaData = ItemData.discriminator('GachaData', gachaSchema);
+
+export { Gacha, GachaData };
diff --git a/web/app/models/GuildModel.ts b/web/app/models/GuildModel.ts
new file mode 100644
index 0000000..c0008ad
--- /dev/null
+++ b/web/app/models/GuildModel.ts
@@ -0,0 +1,26 @@
+import { Schema, model, Document } from 'mongoose';
+
+interface IGuild extends Document {
+ id: string;
+ prefix: string;
+ ponds: string[];
+}
+
+const GuildSchema = new Schema({
+ id: {
+ type: String,
+ required: true,
+ unique: true,
+ },
+ prefix: {
+ type: String,
+ default: '/',
+ },
+ ponds: {
+ type: [String],
+ default: [],
+ },
+});
+
+const Guild = model('Guild', GuildSchema);
+export { Guild };
\ No newline at end of file
diff --git a/web/app/models/HabitatModel.ts b/web/app/models/HabitatModel.ts
new file mode 100644
index 0000000..fa92bd3
--- /dev/null
+++ b/web/app/models/HabitatModel.ts
@@ -0,0 +1,69 @@
+import { Schema, model, Document, Types } from 'mongoose';
+
+interface IHabitat extends Document {
+ name: string;
+ size: number;
+ fish: Types.ObjectId[];
+ waterType: 'Freshwater' | 'Saltwater';
+ temperature: number;
+ cleanliness: number;
+ createdAt: Date;
+ owner: string;
+ lastCleaned: Date;
+ lastAdjusted: Date;
+ type: string;
+}
+
+const AquariumSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ },
+ size: {
+ type: Number,
+ required: true,
+ default: 1,
+ },
+ fish: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Pet',
+ }],
+ waterType: {
+ type: String,
+ enum: ['Freshwater', 'Saltwater'],
+ required: true,
+ },
+ temperature: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ cleanliness: {
+ type: Number,
+ required: true,
+ default: 100,
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now,
+ },
+ owner: {
+ type: String,
+ required: true,
+ },
+ lastCleaned: {
+ type: Date,
+ default: Date.now,
+ },
+ lastAdjusted: {
+ type: Date,
+ default: Date.now,
+ },
+ type: {
+ type: String,
+ default: 'aquarium',
+ },
+});
+
+const Habitat = model('Habitat', AquariumSchema);
+export { Habitat };
\ No newline at end of file
diff --git a/web/app/models/InteractionModel.ts b/web/app/models/InteractionModel.ts
new file mode 100644
index 0000000..32a36ec
--- /dev/null
+++ b/web/app/models/InteractionModel.ts
@@ -0,0 +1,53 @@
+import mongoose from 'mongoose';
+import { Schema, model, Document } from 'mongoose';
+
+interface IInteraction extends Document {
+ command: Schema.Types.ObjectId;
+ user: string;
+ time: Date;
+ channel?: string;
+ guild?: string;
+ interactions: object[];
+ status: 'pending' | 'completed' | 'failed';
+ statusMessage?: string;
+ type: string;
+}
+
+const interactionSchema = new Schema({
+ command: {
+ type: Schema.Types.ObjectId,
+ ref: 'Command',
+ },
+ user: {
+ type: String,
+ required: true,
+ },
+ time: {
+ type: Date,
+ default: Date.now,
+ },
+ channel: {
+ type: String,
+ },
+ guild: {
+ type: String,
+ },
+ interactions: {
+ type: [Object],
+ },
+ status: {
+ type: String,
+ enum: ['pending', 'completed', 'failed'],
+ default: 'pending',
+ },
+ statusMessage: {
+ type: String,
+ },
+ type: {
+ type: String,
+ default: 'interaction',
+ },
+});
+
+const Interaction = mongoose.models.Interaction || model('Interaction', interactionSchema);
+export { Interaction }; export type { IInteraction };
diff --git a/web/app/models/ItemModel.ts b/web/app/models/ItemModel.ts
new file mode 100644
index 0000000..4534e36
--- /dev/null
+++ b/web/app/models/ItemModel.ts
@@ -0,0 +1,69 @@
+import { model, Schema, Document } from 'mongoose';
+
+interface IItem extends Document {
+ name: string;
+ description: string;
+ prerequisites?: string[];
+ rarity: string;
+ price: number;
+ type: string;
+ user?: string;
+ shopItem: boolean;
+ qualities?: string[];
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+}
+
+const itemSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ prerequisites: {
+ type: [String],
+ },
+ rarity: {
+ type: String,
+ required: true,
+ },
+ price: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ type: {
+ type: String,
+ default: 'item',
+ },
+ user: {
+ type: String,
+ },
+ shopItem: {
+ type: Boolean,
+ default: false,
+ },
+ qualities: {
+ type: [String],
+ default: ['weak'],
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+});
+
+const Item = model('Item', itemSchema);
+const ItemData = model('ItemData', itemSchema);
+export { Item, ItemData };
diff --git a/web/app/models/LicenseModel.ts b/web/app/models/LicenseModel.ts
new file mode 100644
index 0000000..d7df01e
--- /dev/null
+++ b/web/app/models/LicenseModel.ts
@@ -0,0 +1,68 @@
+import { Schema, Document, model, Model } from 'mongoose';
+import { Item, ItemData } from './ItemModel';
+
+interface ILicense extends Document {
+ capabilities: string[];
+ biomes: string[];
+ requirements: {
+ level: number;
+ };
+ obtained: Date;
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ aquarium: {
+ waterType: string[];
+ size: number;
+ };
+ type: string;
+}
+
+const LicenseSchema: Schema = new Schema({
+ capabilities: {
+ type: [String],
+ },
+ biomes: {
+ type: [String],
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ },
+ obtained: {
+ type: Date,
+ default: Date.now,
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+ aquarium: {
+ waterType: {
+ type: [String],
+ default: ['freshwater'],
+ },
+ size: {
+ type: Number,
+ default: 1,
+ },
+ },
+ type: {
+ type: String,
+ default: 'license',
+ },
+});
+
+const License: Model = Item.discriminator('License', LicenseSchema);
+const LicenseData: Model = ItemData.discriminator('LicenseData', LicenseSchema);
+
+export { License, LicenseData };
diff --git a/web/app/models/PetModel.ts b/web/app/models/PetModel.ts
new file mode 100644
index 0000000..dd4cce3
--- /dev/null
+++ b/web/app/models/PetModel.ts
@@ -0,0 +1,115 @@
+import { Schema, model, Document, Model } from 'mongoose';
+
+interface IPet extends Document {
+ fish: Schema.Types.ObjectId;
+ aquarium?: Schema.Types.ObjectId;
+ adoptTime: Date;
+ name: string;
+ age: number;
+ owner?: string;
+ traits?: object;
+ health: number;
+ mood: number;
+ hunger: number;
+ stress: number;
+ xp: number;
+ lastFed: Date;
+ lastPlayed: Date;
+ lastBred: Date;
+ lastUpdated: Date;
+ species: string;
+ multiplier: number;
+ attraction: number;
+ type: string;
+}
+
+const PetSchema: Schema = new Schema({
+ fish: {
+ type: Schema.Types.ObjectId,
+ ref: 'Fish',
+ required: true,
+ },
+ aquarium: {
+ type: Schema.Types.ObjectId,
+ ref: 'Aquarium',
+ },
+ adoptTime: {
+ type: Date,
+ default: Date.now,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ age: {
+ type: Number,
+ required: true,
+ },
+ owner: {
+ type: String,
+ },
+ traits: {
+ type: Object,
+ },
+ health: {
+ type: Number,
+ required: true,
+ },
+ mood: {
+ type: Number,
+ required: true,
+ },
+ hunger: {
+ type: Number,
+ required: true,
+ },
+ stress: {
+ type: Number,
+ required: true,
+ },
+ xp: {
+ type: Number,
+ required: true,
+ },
+ lastFed: {
+ type: Date,
+ required: true,
+ default: Date.now,
+ },
+ lastPlayed: {
+ type: Date,
+ required: true,
+ default: Date.now,
+ },
+ lastBred: {
+ type: Date,
+ required: true,
+ default: Date.now,
+ },
+ lastUpdated: {
+ type: Date,
+ required: true,
+ default: Date.now,
+ },
+ species: {
+ type: String,
+ required: true,
+ },
+ multiplier: {
+ type: Number,
+ required: true,
+ default: 1.0,
+ },
+ attraction: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ type: {
+ type: String,
+ default: 'pet',
+ },
+});
+
+const PetFish: Model = model('Pet', PetSchema);
+export { PetFish };
\ No newline at end of file
diff --git a/web/app/models/PondModel.ts b/web/app/models/PondModel.ts
new file mode 100644
index 0000000..6dfb76e
--- /dev/null
+++ b/web/app/models/PondModel.ts
@@ -0,0 +1,36 @@
+import { Schema, model, Document } from 'mongoose';
+
+interface IPond extends Document {
+ id: string;
+ count: number;
+ maximum: number;
+ lastFished: number;
+ warning: boolean;
+}
+
+const PondSchema = new Schema({
+ id: {
+ type: String,
+ required: true,
+ unique: true,
+ },
+ count: {
+ type: Number,
+ default: 2000,
+ },
+ maximum: {
+ type: Number,
+ default: 2000,
+ },
+ lastFished: {
+ type: Number,
+ default: 0,
+ },
+ warning: {
+ type: Boolean,
+ default: false,
+ },
+});
+
+const Pond = model('Pond', PondSchema);
+export { Pond };
\ No newline at end of file
diff --git a/web/app/models/QuestModel.ts b/web/app/models/QuestModel.ts
new file mode 100644
index 0000000..1073a8f
--- /dev/null
+++ b/web/app/models/QuestModel.ts
@@ -0,0 +1,116 @@
+import { Schema, model, Document } from 'mongoose';
+
+interface IQuest extends Document {
+ title: string;
+ description: string;
+ reward?: Schema.Types.ObjectId[];
+ cash: number;
+ xp: number;
+ requirements: {
+ level: number;
+ previous: string[];
+ };
+ startDate?: number;
+ endDate?: number;
+ user?: string;
+ progress: number;
+ progressMax: number;
+ status: 'pending' | 'in_progress' | 'completed' | 'failed';
+ daily: boolean;
+ progressType: {
+ fish: string[];
+ rarity: string[];
+ rod: string;
+ qualities: string[];
+ };
+ type: string;
+}
+
+const questSchema = new Schema({
+ title: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ reward: {
+ type: [{
+ type: Schema.Types.ObjectId,
+ }],
+ },
+ cash: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ xp: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ previous: [{
+ type: String,
+ default: '',
+ }],
+ },
+ startDate: {
+ type: Number,
+ },
+ endDate: {
+ type: Number,
+ },
+ user: {
+ type: String,
+ },
+ progress: {
+ type: Number,
+ default: 0,
+ },
+ progressMax: {
+ type: Number,
+ default: 1,
+ },
+ status: {
+ type: String,
+ enum: ['pending', 'in_progress', 'completed', 'failed'],
+ default: 'pending',
+ },
+ daily: {
+ type: Boolean,
+ default: false,
+ },
+ progressType: {
+ fish: [{
+ type: String,
+ default: 'any',
+ }],
+ rarity: [{
+ type: String,
+ default: 'any',
+ }],
+ rod: {
+ type: String,
+ default: 'any',
+ },
+ qualities: [{
+ type: String,
+ default: 'any',
+ }],
+ },
+ type: {
+ type: String,
+ default: 'quest',
+ },
+});
+
+const Quest = model('Quest', questSchema);
+const QuestData = model('QuestData', questSchema);
+
+export { Quest, QuestData };
diff --git a/web/app/models/RodModel.ts b/web/app/models/RodModel.ts
new file mode 100644
index 0000000..869e39e
--- /dev/null
+++ b/web/app/models/RodModel.ts
@@ -0,0 +1,124 @@
+import { Schema, model, Document, Model } from 'mongoose';
+import { Item, ItemData } from './ItemModel';
+
+interface IRod extends Document {
+ capabilities: string[];
+ requirements: {
+ level: number;
+ };
+ obtained: number;
+ fishCaught: number;
+ state: 'mint' | 'repaired' | 'broken' | 'destroyed';
+ durability: number;
+ maxDurability: number;
+ repairs: number;
+ maxRepairs: number;
+ repairCost: number;
+ icon: {
+ animated: boolean;
+ data: string;
+ };
+ weights: {
+ common: number;
+ uncommon: number;
+ rare: number;
+ ultra: number;
+ giant: number;
+ legendary: number;
+ lucky: number;
+ };
+ type: string;
+}
+
+const rodSchema = new Schema({
+ capabilities: {
+ type: [String],
+ },
+ requirements: {
+ level: {
+ type: Number,
+ default: 0,
+ },
+ },
+ obtained: {
+ type: Number,
+ },
+ fishCaught: {
+ type: Number,
+ default: 0,
+ },
+ state: {
+ type: String,
+ enum: ['mint', 'repaired', 'broken', 'destroyed'],
+ default: 'mint',
+ },
+ durability: {
+ type: Number,
+ default: 1000,
+ },
+ maxDurability: {
+ type: Number,
+ default: 1000,
+ },
+ repairs: {
+ type: Number,
+ default: 0,
+ },
+ maxRepairs: {
+ type: Number,
+ default: 3,
+ },
+ repairCost: {
+ type: Number,
+ default: 1000,
+ },
+ icon: {
+ animated: {
+ type: Boolean,
+ default: false,
+ },
+ data: {
+ type: String,
+ default: 'old_rod:1210508306662301706',
+ },
+ },
+ weights: {
+ common: {
+ type: Number,
+ default: 7000,
+ },
+ uncommon: {
+ type: Number,
+ default: 2500,
+ },
+ rare: {
+ type: Number,
+ default: 500,
+ },
+ ultra: {
+ type: Number,
+ default: 100,
+ },
+ giant: {
+ type: Number,
+ default: 50,
+ },
+ legendary: {
+ type: Number,
+ default: 20,
+ },
+ lucky: {
+ type: Number,
+ default: 1,
+ },
+ },
+ type: {
+ type: String,
+ default: 'rod',
+ },
+});
+
+const Rod: Model = Item.discriminator('Rod', rodSchema);
+const RodData: Model = ItemData.discriminator('RodData', rodSchema);
+
+export { Rod, RodData };
diff --git a/web/app/models/UserModel.ts b/web/app/models/UserModel.ts
new file mode 100644
index 0000000..60175c9
--- /dev/null
+++ b/web/app/models/UserModel.ts
@@ -0,0 +1,148 @@
+import { Schema, model, Document, Types } from 'mongoose';
+import mongoose from 'mongoose';
+
+interface IFishStats {
+ [key: string]: number;
+}
+
+interface IUser extends Document {
+ userId: string;
+ xp: number;
+ commands: number;
+ type: string;
+ currentBiome: string;
+ stats: {
+ fishCaught: number;
+ latestFish: Types.ObjectId[];
+ soldLatestFish: boolean;
+ lastDailyQuest?: number;
+ gachaBoxesOpened: number;
+ fishStats: IFishStats;
+ };
+ inventory: {
+ money: number;
+ equippedRod: Types.ObjectId;
+ equippedBait: Types.ObjectId;
+ items: Types.ObjectId[];
+ baits: Types.ObjectId[];
+ fish: Types.ObjectId[];
+ rods: Types.ObjectId[];
+ quests: Types.ObjectId[];
+ buffs: Types.ObjectId[];
+ gacha: Types.ObjectId[];
+ aquariums: Types.ObjectId[];
+ codes: Types.ObjectId[];
+ };
+ isAdmin: boolean;
+ createdAt: Date;
+}
+
+const UserSchema = new Schema({
+ userId: {
+ type: String,
+ required: true,
+ },
+ xp: {
+ type: Number,
+ default: 0,
+ },
+ commands: {
+ type: Number,
+ default: 0,
+ },
+ type: {
+ type: String,
+ default: 'user',
+ },
+ currentBiome: {
+ type: String,
+ default: 'ocean',
+ },
+ stats: {
+ fishCaught: {
+ type: Number,
+ default: 0,
+ },
+ latestFish: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Fish',
+ }],
+ soldLatestFish: {
+ type: Boolean,
+ default: false,
+ },
+ lastDailyQuest: {
+ type: Number,
+ },
+ gachaBoxesOpened: {
+ type: Number,
+ default: 0,
+ },
+ fishStats: {
+ type: Map,
+ of: Number,
+ default: {},
+ },
+ },
+ inventory: {
+ money: {
+ type: Number,
+ default: 0,
+ },
+ equippedRod: {
+ type: Schema.Types.ObjectId,
+ ref: 'Rod',
+ },
+ equippedBait: {
+ type: Schema.Types.ObjectId,
+ ref: 'Bait',
+ },
+ items: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Item',
+ }],
+ baits: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Bait',
+ }],
+ fish: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Fish',
+ }],
+ rods: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Rod',
+ }],
+ quests: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Quest',
+ }],
+ buffs: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Buff',
+ }],
+ gacha: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Gacha',
+ }],
+ aquariums: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Aquarium',
+ }],
+ codes: [{
+ type: Schema.Types.ObjectId,
+ ref: 'Code',
+ }],
+ },
+ isAdmin: {
+ type: Boolean,
+ default: false,
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now,
+ },
+});
+
+const User = mongoose.models.User || model('User', UserSchema, 'users');
+export { User }; export type { IUser };
\ No newline at end of file
diff --git a/web/app/page.tsx b/web/app/page.tsx
new file mode 100644
index 0000000..5cd5c8a
--- /dev/null
+++ b/web/app/page.tsx
@@ -0,0 +1,52 @@
+import { ArrowRightIcon } from '@heroicons/react/24/outline';
+import Link from 'next/link';
+import styles from '@/app/ui/home.module.css';
+import { lusitana } from '@/app/ui/fonts';
+import Image from 'next/image';
+import FishingRPGLogo from './ui/logo';
+
+export default function Page() {
+ return (
+
+
+ {}
+
+
+
+
+ Welcome to Acme. This is the example for the{' '}
+
+ Next.js Learn Course
+
+ , brought to you by Vercel.
+
+
+
Log in
+
+
+
+ {/* Add Hero Images Here */}
+
+
+
+
+
+ );
+}
diff --git a/web/app/providers.tsx b/web/app/providers.tsx
new file mode 100644
index 0000000..630c90e
--- /dev/null
+++ b/web/app/providers.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { ThemeProvider } from 'next-themes';
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return {children};
+}
\ No newline at end of file
diff --git a/web/app/ui/button.tsx b/web/app/ui/button.tsx
new file mode 100644
index 0000000..af8f627
--- /dev/null
+++ b/web/app/ui/button.tsx
@@ -0,0 +1,19 @@
+import clsx from 'clsx';
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ children: React.ReactNode;
+}
+
+export function Button({ children, className, ...rest }: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/web/app/ui/buttons/signIn.tsx b/web/app/ui/buttons/signIn.tsx
new file mode 100644
index 0000000..b2d0e14
--- /dev/null
+++ b/web/app/ui/buttons/signIn.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import React from 'react';
+import { signIn } from "next-auth/react";
+
+export default function SignInWithDiscord() {
+ return (
+
+ );
+}
diff --git a/web/app/ui/buttons/signOut.tsx b/web/app/ui/buttons/signOut.tsx
new file mode 100644
index 0000000..cbbcf33
--- /dev/null
+++ b/web/app/ui/buttons/signOut.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { signOut } from "next-auth/react";
+
+export default function SignOut() {
+ return ;
+}
diff --git a/web/app/ui/commands/breadcrumbs.tsx b/web/app/ui/commands/breadcrumbs.tsx
new file mode 100644
index 0000000..4b092f9
--- /dev/null
+++ b/web/app/ui/commands/breadcrumbs.tsx
@@ -0,0 +1,36 @@
+import { clsx } from 'clsx';
+import Link from 'next/link';
+import { lusitana } from '@/app/ui/fonts';
+
+interface Breadcrumb {
+ label: string;
+ href: string;
+ active?: boolean;
+}
+
+export default function Breadcrumbs({
+ breadcrumbs,
+}: {
+ breadcrumbs: Breadcrumb[];
+}) {
+ return (
+
+ );
+}
diff --git a/web/app/ui/commands/buttons.tsx b/web/app/ui/commands/buttons.tsx
new file mode 100644
index 0000000..c8b2821
--- /dev/null
+++ b/web/app/ui/commands/buttons.tsx
@@ -0,0 +1,44 @@
+import { PencilIcon, PlusIcon, TrashIcon, EyeIcon } from '@heroicons/react/24/outline';
+import Link from 'next/link';
+
+export function ViewCommand({ id }: { id: string }) {
+ return (
+
+
+
+ );
+}
+
+export function CreateInvoice() {
+ return (
+
+ Create Invoice{' '}
+
+
+ );
+}
+
+export function UpdateInvoice({ id }: { id: string }) {
+ return (
+
+
+
+ );
+}
+
+export function DeleteInvoice({ id }: { id: string }) {
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/web/app/ui/commands/create-form.tsx b/web/app/ui/commands/create-form.tsx
new file mode 100644
index 0000000..35099ce
--- /dev/null
+++ b/web/app/ui/commands/create-form.tsx
@@ -0,0 +1,112 @@
+import { CustomerField } from '@/app/lib/definitions';
+import Link from 'next/link';
+import {
+ CheckIcon,
+ ClockIcon,
+ CurrencyDollarIcon,
+ UserCircleIcon,
+} from '@heroicons/react/24/outline';
+import { Button } from '@/app/ui/button';
+
+export default function Form({ customers }: { customers: CustomerField[] }) {
+ return (
+
+ );
+}
diff --git a/web/app/ui/commands/edit-form.tsx b/web/app/ui/commands/edit-form.tsx
new file mode 100644
index 0000000..8673667
--- /dev/null
+++ b/web/app/ui/commands/edit-form.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import { CustomerField, InvoiceForm } from '@/app/lib/definitions';
+import {
+ CheckIcon,
+ ClockIcon,
+ CurrencyDollarIcon,
+ UserCircleIcon,
+} from '@heroicons/react/24/outline';
+import Link from 'next/link';
+import { Button } from '@/app/ui/button';
+
+export default function EditInvoiceForm({
+ invoice,
+ customers,
+}: {
+ invoice: InvoiceForm;
+ customers: CustomerField[];
+}) {
+ return (
+
+ );
+}
diff --git a/web/app/ui/commands/pagination.tsx b/web/app/ui/commands/pagination.tsx
new file mode 100644
index 0000000..efec116
--- /dev/null
+++ b/web/app/ui/commands/pagination.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
+import clsx from 'clsx';
+import Link from 'next/link';
+import { generatePagination } from '@/app/lib/utils';
+import { usePathname, useSearchParams } from 'next/navigation';
+
+export default function Pagination({ totalPages }: { totalPages: number }) {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const currentPage = Number(searchParams.get('page')) || 1;
+
+ const createPageURL = (pageNumber: number | string) => {
+ const params = new URLSearchParams(searchParams);
+ params.set('page', pageNumber.toString());
+ return `${pathname}?${params.toString()}`;
+ };
+
+ const allPages = generatePagination(currentPage, totalPages);
+
+ return (
+ <>
+
+
+
+
+ {allPages.map((page, index) => {
+ let position: 'first' | 'last' | 'single' | 'middle' | undefined;
+
+ if (index === 0) position = 'first';
+ if (index === allPages.length - 1) position = 'last';
+ if (allPages.length === 1) position = 'single';
+ if (page === '...') position = 'middle';
+
+ return (
+
+ );
+ })}
+
+
+
= totalPages}
+ />
+
+ >
+ );
+}
+
+function PaginationNumber({
+ page,
+ href,
+ isActive,
+ position,
+}: {
+ page: number | string;
+ href: string;
+ position?: 'first' | 'last' | 'middle' | 'single';
+ isActive: boolean;
+}) {
+ const className = clsx(
+ 'flex h-10 w-10 items-center justify-center text-sm border dark:hover:bg-gray-600',
+ {
+ 'rounded-l-md': position === 'first' || position === 'single',
+ 'rounded-r-md': position === 'last' || position === 'single',
+ 'z-10 bg-blue-600 border-blue-600 text-white': isActive,
+ 'hover:bg-gray-100': !isActive && position !== 'middle',
+ 'text-gray-300': position === 'middle',
+ },
+ );
+
+ return isActive || position === 'middle' ? (
+ {page}
+ ) : (
+
+ {page}
+
+ );
+}
+
+function PaginationArrow({
+ href,
+ direction,
+ isDisabled,
+}: {
+ href: string;
+ direction: 'left' | 'right';
+ isDisabled?: boolean;
+}) {
+ const className = clsx(
+ 'flex h-10 w-10 items-center justify-center rounded-md border dark:hover:bg-gray-600',
+ {
+ 'pointer-events-none text-gray-300': isDisabled,
+ 'hover:bg-gray-100': !isDisabled,
+ 'mr-2 md:mr-4': direction === 'left',
+ 'ml-2 md:ml-4': direction === 'right',
+ },
+ );
+
+ const icon =
+ direction === 'left' ? (
+
+ ) : (
+
+ );
+
+ return isDisabled ? (
+ {icon}
+ ) : (
+
+ {icon}
+
+ );
+}
diff --git a/web/app/ui/commands/status.tsx b/web/app/ui/commands/status.tsx
new file mode 100644
index 0000000..108bda5
--- /dev/null
+++ b/web/app/ui/commands/status.tsx
@@ -0,0 +1,29 @@
+import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline';
+import clsx from 'clsx';
+
+export default function InvoiceStatus({ status }: { status: string }) {
+ return (
+
+ {status === 'pending' ? (
+ <>
+ Pending
+
+ >
+ ) : null}
+ {status === 'paid' ? (
+ <>
+ Paid
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/web/app/ui/commands/table.tsx b/web/app/ui/commands/table.tsx
new file mode 100644
index 0000000..dbea8f3
--- /dev/null
+++ b/web/app/ui/commands/table.tsx
@@ -0,0 +1,96 @@
+import Image from 'next/image';
+import { UpdateInvoice, DeleteInvoice, ViewCommand } from '@/app/ui/commands/buttons';
+import InvoiceStatus from '@/app/ui/commands/status';
+import { fetchFilteredCommands } from '@/app/lib/data';
+
+export default async function CommandsTable({
+ query,
+ currentPage,
+}: {
+ query: string;
+ currentPage: number;
+}) {
+ const commands = await fetchFilteredCommands(query, currentPage);
+
+ return (
+
+
+
+
+ {commands?.map((command) => (
+
+
+
+
+
{new Date(command.time).toLocaleString()}
+
+
+
+
+
+
+ {/* {formatCurrency(invoice.amount)} */}
+
+ {/*
{formatDateToLocal(invoice.date)}
*/}
+
+
+ {}
+
+
+
+ ))}
+
+
+
+
+ |
+ Command
+ |
+
+ User
+ |
+
+ Time
+ |
+
+ Edit
+ |
+
+
+
+ {commands?.map((command) => (
+
+ |
+
+ |
+
+ {command.user}
+ |
+
+ {/* */}
+ {new Date(command.time).toLocaleString()}
+ |
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/web/app/ui/dashboard/cards.tsx b/web/app/ui/dashboard/cards.tsx
new file mode 100644
index 0000000..c4fb33c
--- /dev/null
+++ b/web/app/ui/dashboard/cards.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { CardsSkeleton } from '../skeletons';
+import { BanknotesIcon, ClockIcon, UserGroupIcon, InboxIcon, ArrowTrendingUpIcon, ArrowTrendingDownIcon } from '@heroicons/react/24/outline';
+import { lusitana } from '@/app/ui/fonts';
+
+const iconMap = {
+ fish: BanknotesIcon,
+ users: UserGroupIcon,
+ commands: InboxIcon,
+};
+
+export default function CardWrapper() {
+ const [data, setData] = useState({
+ commands: 0,
+ commandTrend: 0,
+ users: 0,
+ userTrend: 0,
+ fish: 0,
+ fishTrend: 0,
+ });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let ignore = false;
+ const eventSource = new EventSource(`/api/stream/countData`);
+
+ const fetchData = async () => {
+ try {
+ eventSource.onmessage = (event) => {
+ const newData = JSON.parse(event.data);
+ setData(newData);
+
+ if (!ignore) setLoading(false);
+ };
+
+ eventSource.onerror = (event) => {
+ console.log("Connection was closed due to an error:", event);
+ eventSource.close();
+ };
+ } catch (err) {
+ console.error('Fetch error:', err);
+ eventSource.close();
+ if (!ignore) setError(err);
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+
+ return () => {
+ ignore = true;
+ console.log("Closing EventSource for countData");
+ eventSource.close();
+ }
+ }, []);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
Latest Commands
+
Error fetching commands: {error.message}
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export function Card({
+ title,
+ value,
+ type,
+ percent,
+}: {
+ title: string;
+ value: number | string;
+ type: 'commands' | 'users' | 'fish',
+ percent: number;
+}) {
+ const Icon = iconMap[type];
+ const color = type === 'commands' ? 'bg-blue-50' : type === 'users' ? 'bg-green-50' : type === 'fish' ? 'bg-yellow-50' : 'bg-gray-50';
+ const iconColor = type === 'commands' ? 'text-blue-700' : type === 'users' ? 'text-green-700' : type === 'fish' ? 'text-yellow-700' : 'text-gray-700';
+ const TrendIcon = percent > 0 ? ArrowTrendingUpIcon : ArrowTrendingDownIcon;
+ const percentColor = percent > 0 ? 'bg-green-500' : 'bg-red-500';
+
+ return (
+
+
+
+ {Icon ? : null}
+
{title}
+
+
{value.toLocaleString()}
+
+
+
+
+ {percent.toString().replace('-', '')}%
+
+
+
+
+ );
+}
diff --git a/web/app/ui/dashboard/commands-chart.tsx b/web/app/ui/dashboard/commands-chart.tsx
new file mode 100644
index 0000000..a0276c9
--- /dev/null
+++ b/web/app/ui/dashboard/commands-chart.tsx
@@ -0,0 +1,69 @@
+'use server';
+
+import { generateYAxis } from '@/app/lib/utils';
+import { CalendarIcon } from '@heroicons/react/24/outline';
+import { lusitana } from '@/app/ui/fonts';
+import { fetchCommands } from '@/app/lib/data';
+
+// This component is representational only.
+// For data visualization UI, check out:
+// https://www.tremor.so/
+// https://www.chartjs.org/
+// https://airbnb.io/visx/
+
+export default async function CommandsChart() {
+ const commands = await fetchCommands();
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+ let monthlyCommands: { month: string; count: number }[] = [];
+
+ for (let i = 0; i < 12; i++) {
+ const month = months[i];
+ const count = commands.filter((command) => new Date(command.time).toLocaleString('default', { month: 'short' }) === month).length;
+ monthlyCommands.push({ month, count });
+ }
+
+ const chartHeight = 350;
+ const { yAxisLabels, topLabel } = generateYAxis(monthlyCommands);
+
+ if (!commands || commands.length === 0) {
+ return No data available.
;
+ }
+
+ return (
+
+
+ Recent Command Usage
+
+
+
+
+ {yAxisLabels.map((label) => (
+
{label}
+ ))}
+
+
+ {monthlyCommands.map((monthCommands, index) => (
+
+
+
+ {monthCommands.month}
+
+
+ ))}
+
+
+
+
Last 12 months
+
+
+
+ );
+}
diff --git a/web/app/ui/dashboard/latest-commands.tsx b/web/app/ui/dashboard/latest-commands.tsx
new file mode 100644
index 0000000..04f8277
--- /dev/null
+++ b/web/app/ui/dashboard/latest-commands.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { ArrowPathIcon } from '@heroicons/react/24/outline';
+import clsx from 'clsx';
+import { lusitana } from '@/app/ui/fonts';
+import { LatestCommandsSkeleton } from '../skeletons';
+
+const API = '/api/latest/commands';
+
+export default function LatestCommands() {
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let ignore = false;
+ const eventSource = new EventSource(`/api/stream?collection=commands`);
+
+ const fetchData = async () => {
+ try {
+ eventSource.onmessage = (event) => {
+ const newData = JSON.parse(event.data);
+ if (newData.length === 1) {
+ setData((prevData) => [newData[0], ...prevData].slice(0, 10));
+ }
+ else {
+ setData(newData);
+ }
+
+ if (!ignore) setLoading(false);
+ };
+
+ eventSource.onerror = (event) => {
+ console.log("Connection was closed due to an error:", event);
+ eventSource.close();
+ };
+ } catch (err) {
+ console.error('Fetch error:', err);
+ eventSource.close();
+ if (!ignore) setError(err);
+ }
+ };
+
+ fetchData();
+
+ return () => {
+ ignore = true;
+ console.log("Closing EventSource for commands");
+ eventSource.close();
+ }
+ }, []);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
Latest Commands
+
Error fetching commands: {error.message}
+
+ );
+ }
+
+ if (data.length === 0) {
+ return (
+
+
Latest Commands
+
No commands available.
+
+ );
+ }
+
+ return (
+
+
Latest Commands
+
+ {data.map((command, i) => (
+
+
+
+
+ {new Date(command.time).toLocaleString()}
+
+
{command.user}
+
+
+
+ {command.command}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/web/app/ui/dashboard/nav-links.tsx b/web/app/ui/dashboard/nav-links.tsx
new file mode 100644
index 0000000..52ecfb4
--- /dev/null
+++ b/web/app/ui/dashboard/nav-links.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import {
+ UserGroupIcon,
+ HomeIcon,
+ DocumentDuplicateIcon,
+} from '@heroicons/react/24/outline';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import clsx from 'clsx';
+
+// Map of links to display in the side navigation.
+// Depending on the size of the application, this would be stored in a database.
+const links = [
+ { name: 'Home', href: '/dashboard', icon: HomeIcon },
+ {
+ name: 'Commands',
+ href: '/dashboard/commands',
+ icon: DocumentDuplicateIcon,
+ },
+ { name: 'Users', href: '/dashboard/users', icon: UserGroupIcon },
+];
+
+export default function NavLinks() {
+ const pathname = usePathname();
+ return (
+ <>
+ {links.map((link) => {
+ const LinkIcon = link.icon;
+ return (
+
+
+ {link.name}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/web/app/ui/dashboard/sidenav.tsx b/web/app/ui/dashboard/sidenav.tsx
new file mode 100644
index 0000000..93d1a0f
--- /dev/null
+++ b/web/app/ui/dashboard/sidenav.tsx
@@ -0,0 +1,41 @@
+import Link from 'next/link';
+import NavLinks from '@/app/ui/dashboard/nav-links';
+import FishingRPGLogo from '@/app/ui/logo';
+import { PowerIcon } from '@heroicons/react/24/outline';
+import { auth, signOut } from '@/auth';
+
+export default async function SideNav() {
+ const session = await auth();
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/web/app/ui/dashboard/topnav.tsx b/web/app/ui/dashboard/topnav.tsx
new file mode 100644
index 0000000..424fe4e
--- /dev/null
+++ b/web/app/ui/dashboard/topnav.tsx
@@ -0,0 +1,47 @@
+import { auth } from '@/auth';
+import FishingRPGLogo from '../logo';
+
+const TopNav = async () => {
+ const session = await auth();
+
+ if (session) {
+ return (
+ <>
+
+ >
+ );
+ } else {
+ return (
+ <>
+
+ >
+ );
+ }
+};
+
+export default TopNav;
diff --git a/web/app/ui/fonts.tsx b/web/app/ui/fonts.tsx
new file mode 100644
index 0000000..e93c500
--- /dev/null
+++ b/web/app/ui/fonts.tsx
@@ -0,0 +1,7 @@
+import { Inter, Lusitana } from 'next/font/google';
+
+export const inter = Inter({ subsets: ['latin'] });
+export const lusitana = Lusitana({
+ subsets: ['latin'],
+ weight: ['400', '700']
+ });
\ No newline at end of file
diff --git a/web/app/ui/global.css b/web/app/ui/global.css
new file mode 100644
index 0000000..c06d6d6
--- /dev/null
+++ b/web/app/ui/global.css
@@ -0,0 +1,18 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+input[type='number'] {
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+
+input[type='number']::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+input[type='number']::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
diff --git a/web/app/ui/home.module.css b/web/app/ui/home.module.css
new file mode 100644
index 0000000..527ad3f
--- /dev/null
+++ b/web/app/ui/home.module.css
@@ -0,0 +1,7 @@
+.shape {
+ height: 0;
+ width: 0;
+ border-bottom: 30px solid black;
+ border-left: 20px solid transparent;
+ border-right: 20px solid transparent;
+}
\ No newline at end of file
diff --git a/web/app/ui/login-form.tsx b/web/app/ui/login-form.tsx
new file mode 100644
index 0000000..7785783
--- /dev/null
+++ b/web/app/ui/login-form.tsx
@@ -0,0 +1,26 @@
+import { auth } from '@/auth'
+import type {NextPage} from 'next'
+import SignOut from './buttons/signOut'
+import SignInWithDiscord from './buttons/signIn'
+
+const Home: NextPage = async () => {
+ const session = await auth()
+
+ if (session) {
+ return (
+ <>
+ You're signed in! Congratulations!
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default Home
diff --git a/web/app/ui/logo.tsx b/web/app/ui/logo.tsx
new file mode 100644
index 0000000..423f436
--- /dev/null
+++ b/web/app/ui/logo.tsx
@@ -0,0 +1,13 @@
+import { GlobeAltIcon } from '@heroicons/react/24/outline';
+import { lusitana } from '@/app/ui/fonts';
+
+export default function FishingRPGLogo() {
+ return (
+
+ );
+}
diff --git a/web/app/ui/search.tsx b/web/app/ui/search.tsx
new file mode 100644
index 0000000..8407853
--- /dev/null
+++ b/web/app/ui/search.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { useSearchParams, usePathname, useRouter } from 'next/navigation';
+import { useDebouncedCallback } from 'use-debounce';
+
+export default function Search({ placeholder }: { placeholder: string }) {
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const { replace } = useRouter();
+
+ const handleSearch = useDebouncedCallback((term) => {
+ const params = new URLSearchParams(searchParams);
+ params.set('page', '1');
+ if (term) {
+ params.set('query', term);
+ } else {
+ params.delete('query');
+ }
+ replace(`${pathname}?${params.toString()}`);
+ }, 300);
+
+ return (
+
+
+ {
+ handleSearch(e.target.value);
+ }}
+ defaultValue={searchParams.get('query')?.toString()}
+ />
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/app/ui/skeletons.tsx b/web/app/ui/skeletons.tsx
new file mode 100644
index 0000000..9e1f005
--- /dev/null
+++ b/web/app/ui/skeletons.tsx
@@ -0,0 +1,218 @@
+// Loading animation
+const shimmer =
+ 'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent';
+
+export function CardSkeleton() {
+ return (
+
+ );
+}
+
+export function CardsSkeleton() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export function ChartSkeleton() {
+ return (
+
+ );
+}
+
+export function CommandSkeleton() {
+ return (
+
+ );
+}
+
+export function LatestCommandsSkeleton() {
+ return (
+
+ );
+}
+
+export default function DashboardSkeleton() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function TableRowSkeleton() {
+ return (
+
+ {/* Customer Name and Image */}
+ |
+
+ |
+ {/* Email */}
+
+
+ |
+ {/* Amount */}
+
+
+ |
+ {/* Date */}
+
+
+ |
+ {/* Status */}
+
+
+ |
+ {/* Actions */}
+
+
+ |
+
+ );
+}
+
+export function CommandsMobileSkeleton() {
+ return (
+
+ );
+}
+
+export function CommandsTableSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ Customer
+ |
+
+ Email
+ |
+
+ Amount
+ |
+
+ Date
+ |
+
+ Status
+ |
+
+ Edit
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/app/ui/users/buttons.tsx b/web/app/ui/users/buttons.tsx
new file mode 100644
index 0000000..0edfca3
--- /dev/null
+++ b/web/app/ui/users/buttons.tsx
@@ -0,0 +1,36 @@
+import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
+import Link from 'next/link';
+
+export function CreateInvoice() {
+ return (
+
+ Create Invoice{' '}
+
+
+ );
+}
+
+export function UpdateInvoice({ id }: { id: string }) {
+ return (
+
+
+
+ );
+}
+
+export function DeleteInvoice({ id }: { id: string }) {
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/web/app/ui/users/pagination.tsx b/web/app/ui/users/pagination.tsx
new file mode 100644
index 0000000..9545f9a
--- /dev/null
+++ b/web/app/ui/users/pagination.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
+import clsx from 'clsx';
+import Link from 'next/link';
+import { generatePagination } from '@/app/lib/utils';
+import { usePathname, useSearchParams } from 'next/navigation';
+
+export default function Pagination({ totalPages }: { totalPages: number }) {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const currentPage = Number(searchParams.get('page')) || 1;
+
+ const createPageURL = (pageNumber: number | string) => {
+ const params = new URLSearchParams(searchParams);
+ params.set('page', pageNumber.toString());
+ return `${pathname}?${params.toString()}`;
+ };
+
+ const allPages = generatePagination(currentPage, totalPages);
+
+ return (
+ <>
+
+
+
+
+ {allPages.map((page, index) => {
+ let position: 'first' | 'last' | 'single' | 'middle' | undefined;
+
+ if (index === 0) position = 'first';
+ if (index === allPages.length - 1) position = 'last';
+ if (allPages.length === 1) position = 'single';
+ if (page === '...') position = 'middle';
+
+ return (
+
+ );
+ })}
+
+
+
= totalPages}
+ />
+
+ >
+ );
+}
+
+function PaginationNumber({
+ page,
+ href,
+ isActive,
+ position,
+}: {
+ page: number | string;
+ href: string;
+ position?: 'first' | 'last' | 'middle' | 'single';
+ isActive: boolean;
+}) {
+ const className = clsx(
+ 'flex h-10 w-10 items-center justify-center text-sm border',
+ {
+ 'rounded-l-md': position === 'first' || position === 'single',
+ 'rounded-r-md': position === 'last' || position === 'single',
+ 'z-10 bg-blue-600 border-blue-600 text-white': isActive,
+ 'hover:bg-gray-100': !isActive && position !== 'middle',
+ 'text-gray-300': position === 'middle',
+ },
+ );
+
+ return isActive || position === 'middle' ? (
+ {page}
+ ) : (
+
+ {page}
+
+ );
+}
+
+function PaginationArrow({
+ href,
+ direction,
+ isDisabled,
+}: {
+ href: string;
+ direction: 'left' | 'right';
+ isDisabled?: boolean;
+}) {
+ const className = clsx(
+ 'flex h-10 w-10 items-center justify-center rounded-md border',
+ {
+ 'pointer-events-none text-gray-300': isDisabled,
+ 'hover:bg-gray-100': !isDisabled,
+ 'mr-2 md:mr-4': direction === 'left',
+ 'ml-2 md:ml-4': direction === 'right',
+ },
+ );
+
+ const icon =
+ direction === 'left' ? (
+
+ ) : (
+
+ );
+
+ return isDisabled ? (
+ {icon}
+ ) : (
+
+ {icon}
+
+ );
+}
diff --git a/web/app/ui/users/table.tsx b/web/app/ui/users/table.tsx
new file mode 100644
index 0000000..13756d6
--- /dev/null
+++ b/web/app/ui/users/table.tsx
@@ -0,0 +1,113 @@
+import Image from 'next/image';
+import { lusitana } from '@/app/ui/fonts';
+import Search from '@/app/ui/search';
+import { fetchFilteredUsers } from '@/app/lib/data';
+
+export default async function UsersTable({
+ query,
+ currentPage,
+}: {
+ query: string;
+ currentPage: number;
+}) {
+ const users = await fetchFilteredUsers(query, currentPage);
+ return (
+
+
+
+
+
+
+ {users?.map((user) => (
+
+
+
+
+
+ {/*
*/}
+
{user.userId}
+
+
+
+ {user.isAdmin ? 'Admin' : 'User'}
+
+
+
+
+
+ {/*
+
Commands
+
{user.commands}
+
*/}
+
+
+
{user.commands} commands
+
+
+ ))}
+
+
+
+
+ |
+ UserId
+ |
+
+ XP
+ |
+
+ Total Commands
+ |
+
+ Total Money
+ |
+
+
+
+
+ {users.map((user) => (
+
+
+
+ {/* */}
+ {user.userId}
+
+ |
+
+ {user.xp.toLocaleString()}
+ |
+
+ {user.commands.toLocaleString()}
+ |
+
+ ${user.inventory.money.toLocaleString()}
+ |
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/auth.config.ts b/web/auth.config.ts
new file mode 100644
index 0000000..ca87d46
--- /dev/null
+++ b/web/auth.config.ts
@@ -0,0 +1,22 @@
+import type { NextAuthConfig } from 'next-auth';
+
+export const authConfig = {
+ pages: {
+ signIn: '/login',
+ },
+ callbacks: {
+ async authorized({ auth, request: { nextUrl } }) {
+ const isLoggedIn = !!auth?.user;
+
+ const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
+ if (isOnDashboard) {
+ if (isLoggedIn) return true;
+ return false; // Redirect unauthenticated users to login page
+ } else if (isLoggedIn) {
+ return Response.redirect(new URL('/dashboard', nextUrl));
+ }
+ return true;
+ },
+ },
+ providers: [], // Add providers with an empty array for now
+} satisfies NextAuthConfig;
\ No newline at end of file
diff --git a/web/auth.ts b/web/auth.ts
new file mode 100644
index 0000000..ba1c67b
--- /dev/null
+++ b/web/auth.ts
@@ -0,0 +1,26 @@
+import NextAuth from 'next-auth'
+import { authConfig } from './auth.config';
+import Discord from "next-auth/providers/discord"
+
+// https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes
+const scopes = ['identify'].join(' ')
+
+export const { handlers, signIn, signOut, auth } = NextAuth({
+ providers: [Discord({
+ clientId: process.env.CLIENT_ID,
+ clientSecret: process.env.CLIENT_SECRET,
+ profile: async (profile) => {
+ let userAvatar = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
+ return {
+ id: profile.id,
+ snowflake: profile.id,
+ username: profile.username,
+ image: userAvatar,
+ name: profile.global_name
+ }
+ }
+ })],
+ pages: {
+ signIn: "/login",
+ },
+})
\ No newline at end of file
diff --git a/web/lib/dbConnect.ts b/web/lib/dbConnect.ts
new file mode 100644
index 0000000..c0c90dc
--- /dev/null
+++ b/web/lib/dbConnect.ts
@@ -0,0 +1,73 @@
+import mongoose from 'mongoose';
+declare global {
+ // eslint-disable-next-line no-var, no-shadow, no-unused-vars
+ var mongoose: any;
+}
+
+const MONGODB_URI = process.env.MONGODB_URI!;
+
+if (!MONGODB_URI) {
+ throw new Error(
+ 'Please define the MONGODB_URI environment variable inside .env.local',
+ );
+}
+
+let cached = global.mongoose;
+
+if (!cached) {
+ cached = global.mongoose = { conn: null, promise: null };
+}
+
+async function dbConnect() {
+ if (cached.conn) {
+ return cached.conn;
+ }
+ if (!cached.promise) {
+ const opts = {
+ bufferCommands: false,
+ };
+ // eslint-disable-next-line no-shadow
+ cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
+ return mongoose;
+ });
+ }
+ try {
+ cached.conn = await cached.promise;
+ }
+ catch (e) {
+ cached.promise = null;
+ throw e;
+ }
+
+ return cached.conn;
+}
+
+export default dbConnect;
+
+export async function setupChangeStream(collectionName: string, pipeline, callback: (change: any) => void) {
+ await dbConnect();
+ const db = mongoose.connection.db;
+ const collection = db.collection(collectionName);
+
+ const changeStream = collection.watch(pipeline, {
+ fullDocument: "updateLookup",
+ });
+
+ changeStream.on('change', (change) => {
+ try {
+ callback(change);
+ } catch (callbackError) {
+ console.error('Error in change stream callback:', callbackError);
+ }
+ });
+
+ changeStream.on('error', (error) => {
+ console.error('Change stream error:', error);
+ });
+
+ changeStream.on('end', () => {
+ console.log('Change stream ended');
+ });
+
+ return changeStream;
+}
diff --git a/web/middleware.ts b/web/middleware.ts
new file mode 100644
index 0000000..1fdda5b
--- /dev/null
+++ b/web/middleware.ts
@@ -0,0 +1,9 @@
+import NextAuth from 'next-auth';
+import { authConfig } from './auth.config';
+
+export default NextAuth(authConfig).auth;
+
+export const config = {
+ // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
+ matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
+};
\ No newline at end of file
diff --git a/web/next-env.d.ts b/web/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/web/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/web/next.config.js b/web/next.config.js
new file mode 100644
index 0000000..1f3db8f
--- /dev/null
+++ b/web/next.config.js
@@ -0,0 +1,9 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ experimental: {
+ reactCompiler: true,
+ },
+}
+
+module.exports = nextConfig
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..5315f6c
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,47 @@
+{
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev",
+ "prettier": "prettier --write --ignore-unknown .",
+ "prettier:check": "prettier --check --ignore-unknown .",
+ "start": "next start"
+ },
+ "dependencies": {
+ "@heroicons/react": "^2.0.18",
+ "@tailwindcss/forms": "^0.5.7",
+ "@types/node": "20.5.7",
+ "@vercel/postgres": "^0.5.0",
+ "autoprefixer": "10.4.15",
+ "babel-plugin-react-compiler": "^0.0.0-experimental-696af53-20240625",
+ "bcrypt": "^5.1.1",
+ "clsx": "^2.0.0",
+ "mongoose": "^8.4.1",
+ "next": "15.0.0-rc.0",
+ "next-auth": "^5.0.0-beta.19",
+ "next-themes": "^0.3.0",
+ "postcss": "8.4.31",
+ "react": "19.0.0-rc-6230622a1a-20240610",
+ "react-dom": "19.0.0-rc-6230622a1a-20240610",
+ "swr": "^2.2.5",
+ "tailwindcss": "3.3.3",
+ "typescript": "5.2.2",
+ "use-debounce": "^10.0.1",
+ "zod": "^3.22.2"
+ },
+ "devDependencies": {
+ "@types/bcrypt": "^5.0.1",
+ "@types/react": "18.2.21",
+ "@types/react-dom": "18.2.14",
+ "@vercel/style-guide": "^5.0.1",
+ "dotenv": "^16.3.1",
+ "eslint": "^8.52.0",
+ "eslint-config-next": "15.0.0-rc.0",
+ "eslint-config-prettier": "9.0.0",
+ "prettier": "^3.0.3",
+ "prettier-plugin-tailwindcss": "0.5.4"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ }
+}
diff --git a/web/postcss.config.js b/web/postcss.config.js
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/web/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/web/prettier.config.js b/web/prettier.config.js
new file mode 100644
index 0000000..e9d2d0d
--- /dev/null
+++ b/web/prettier.config.js
@@ -0,0 +1,6 @@
+const styleguide = require('@vercel/style-guide/prettier');
+
+module.exports = {
+ ...styleguide,
+ plugins: [...styleguide.plugins, 'prettier-plugin-tailwindcss'],
+};
diff --git a/web/public/favicon.ico b/web/public/favicon.ico
new file mode 100644
index 0000000..af98450
Binary files /dev/null and b/web/public/favicon.ico differ
diff --git a/web/public/hero-desktop.png b/web/public/hero-desktop.png
new file mode 100644
index 0000000..de8a5ce
Binary files /dev/null and b/web/public/hero-desktop.png differ
diff --git a/web/public/hero-mobile.png b/web/public/hero-mobile.png
new file mode 100644
index 0000000..bb60e7d
Binary files /dev/null and b/web/public/hero-mobile.png differ
diff --git a/web/public/opengraph-image.png b/web/public/opengraph-image.png
new file mode 100644
index 0000000..569707e
Binary files /dev/null and b/web/public/opengraph-image.png differ
diff --git a/web/server.js b/web/server.js
new file mode 100644
index 0000000..350497a
--- /dev/null
+++ b/web/server.js
@@ -0,0 +1,29 @@
+import { createServer } from "node:http";
+import next from "next";
+import { Server } from "socket.io";
+
+const dev = process.env.NODE_ENV !== "production";
+const hostname = "localhost";
+const port = 3001;
+// when using middleware `hostname` and `port` must be provided below
+const app = next({ dev, hostname, port });
+const handler = app.getRequestHandler();
+
+app.prepare().then(() => {
+ const httpServer = createServer(handler);
+
+ const io = new Server(httpServer);
+
+ io.on("connection", (socket) => {
+ // ...
+ });
+
+ httpServer
+ .once("error", (err) => {
+ console.error(err);
+ process.exit(1);
+ })
+ .listen(port, () => {
+ console.log(`> Ready on http://${hostname}:${port}`);
+ });
+});
\ No newline at end of file
diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts
new file mode 100644
index 0000000..f8d31e2
--- /dev/null
+++ b/web/tailwind.config.ts
@@ -0,0 +1,33 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+ content: [
+ './pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './components/**/*.{js,ts,jsx,tsx,mdx}',
+ './app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ theme: {
+ extend: {
+ gridTemplateColumns: {
+ '13': 'repeat(13, minmax(0, 1fr))',
+ },
+ colors: {
+ blue: {
+ 400: '#2589FE',
+ 500: '#0070F3',
+ 600: '#2F6FEB',
+ },
+ },
+ },
+ keyframes: {
+ shimmer: {
+ '100%': {
+ transform: 'translateX(100%)',
+ },
+ },
+ },
+ },
+ plugins: [require('@tailwindcss/forms')],
+ darkMode: 'class',
+};
+export default config;
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..82de69d
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "app/lib/placeholder-data.js",
+ "scripts/seed.js"
+, "server.js" ],
+ "exclude": ["node_modules"]
+}