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 ( +
+ + {/* */} +
+
+ +
+
{children}
+
+
+
+ ); +} \ 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 ( +
+
+ {/* Customer Name */} +
+ +
+ + +
+
+ + {/* Invoice Amount */} +
+ +
+
+ + +
+
+
+ + {/* Invoice Status */} +
+ + Set the invoice status + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ + Cancel + + +
+ + ); +} 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 ( +
+
+ {/* Customer Name */} +
+ +
+ + +
+
+ + {/* Invoice Amount */} +
+ +
+
+ + +
+
+
+ + {/* Invoice Status */} +
+ + Set the invoice status + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ + Cancel + + +
+ + ); +} 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) => ( +
+
+
+
+

{command.command}

+
+

{new Date(command.time).toLocaleString()}

+
+ +
+
+
+

+ {/* {formatCurrency(invoice.amount)} */} +

+ {/*

{formatDateToLocal(invoice.date)}

*/} +
+
+ {} +
+
+
+ ))} +
+
+ + + + + + + + + + {commands?.map((command) => ( + + + + + + + ))} + +
+ Command + + User + + Time + + Edit +
+
+

{command.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} +

+
+ ))} +
+ +

Updated just now

+
+
+
+ ); +} 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 ( +
+
+ {} +
+
+ +
+
{ + 'use server'; + await signOut(); + }} + > +
+ {session && session.user && ( + <> + Profile Picture +
{session.user.name}
+ + )} +
+ +
+
+
+
+ ); +} \ 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 ( +
+ +

Fishing

+
+ ); +} 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) => ( +
+
+
+
+
+ {/* {`${customer.name}'s */} +

{user.userId}

+
+
+

+ {user.isAdmin ? 'Admin' : 'User'} +

+
+
+
+
+

XP

+

{user.xp}

+
+ {/*
+

Commands

+

{user.commands}

+
*/} +
+
+

{user.commands} commands

+
+
+ ))} +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
+ UserId + + XP + + Total Commands + + Total Money +
+
+ {/* {`${customer.name}'s */} +

{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"] +}