diff --git a/apps/admin/app/dashboard/layout.tsx b/apps/admin/app/dashboard/layout.tsx index 7923ae0..4b32e70 100644 --- a/apps/admin/app/dashboard/layout.tsx +++ b/apps/admin/app/dashboard/layout.tsx @@ -5,6 +5,10 @@ const URLS = [ label: "Tracks", href: "tracks", }, + { + label: "Reports", + href: "reports", + }, ]; function DashboardLayout({ children }: { children: ReactNode }) { diff --git a/apps/admin/app/dashboard/reports/actions.ts b/apps/admin/app/dashboard/reports/actions.ts new file mode 100644 index 0000000..cb173b5 --- /dev/null +++ b/apps/admin/app/dashboard/reports/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { auth } from "@/auth"; +import { assertAdmin } from "@/utils/auth"; +import { prisma } from "@repo/db"; +import { revalidatePath } from "next/cache"; + +export async function DeleteReportedComment(reportId: string) { + const session = await auth(); + if (!assertAdmin(session)) return { error: "Unauthorized" }; + const report = await prisma.report.findUnique({ + where: { id: reportId }, + }); + if (!report) return { error: "Report not found" }; + const tx = await prisma.$transaction([ + prisma.comment.delete({ + where: { + id: report.commentId!, + }, + }), + prisma.report.update({ + where: { id: reportId }, + data: { + status: "accepted", + }, + }), + ]); + + revalidatePath("/dashboard/reports"); +} + +export async function RejectReportedComment(reportId: string) { + const session = await auth(); + if (!assertAdmin(session)) return { error: "Unauthorized" }; + const report = await prisma.report.findUnique({ + where: { id: reportId }, + }); + if (!report) return { error: "Report not found" }; + const tx = await prisma.report.update({ + where: { id: reportId }, + data: { + status: "rejected", + }, + }); + + revalidatePath("/dashboard/reports"); +} diff --git a/apps/admin/app/dashboard/reports/loading.tsx b/apps/admin/app/dashboard/reports/loading.tsx new file mode 100644 index 0000000..af887c2 --- /dev/null +++ b/apps/admin/app/dashboard/reports/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/shared/loading"; diff --git a/apps/admin/app/dashboard/reports/page.tsx b/apps/admin/app/dashboard/reports/page.tsx new file mode 100644 index 0000000..624adf0 --- /dev/null +++ b/apps/admin/app/dashboard/reports/page.tsx @@ -0,0 +1,117 @@ +import { auth } from "@/auth"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { assertAdmin } from "@/utils/auth"; +import { fromNow } from "@/utils/time"; +import { prisma } from "@repo/db"; +import { cn, parseToNumber, upperFirst } from "@repo/utils"; +import { redirect } from "next/navigation"; +import ViewReportContent from "./view.client"; +async function Reports({ + searchParams, +}: { + searchParams: Record; +}) { + const session = await auth(); + if (assertAdmin(session) === false) redirect(`/no-access`); + const reports = await prisma.report.paginate({ + limit: parseToNumber(searchParams.limit as string, 100), + page: parseToNumber(searchParams.page as string, 1), + where: { + commentId: { + not: null, + }, + }, + orderBy: [ + { + status: "desc", + }, + { + createdAt: "asc", + }, + ], + include: { + author: { + select: { + id: true, + username: true, + name: true, + }, + }, + comment: { + select: { + id: true, + content: true, + createdAt: true, + author: { + select: { + username: true, + name: true, + id: true, + }, + }, + }, + }, + }, + }); + return ( +
+
+
+
{/* */}
+
+ + + + Reporter + Report + Status + Reported At + Actions + + + + {reports.result.map((report) => ( + + {report.author.name} + {report.content?.substring(0, 30)}... + + + {upperFirst(report.status)} + + + {fromNow(report.createdAt)} + + + + + ))} + +
+
+
+ ); +} + +export default Reports; diff --git a/apps/admin/app/dashboard/reports/view.client.tsx b/apps/admin/app/dashboard/reports/view.client.tsx new file mode 100644 index 0000000..0f3883f --- /dev/null +++ b/apps/admin/app/dashboard/reports/view.client.tsx @@ -0,0 +1,130 @@ +"use client"; +import { AvatarFallback, AvatarImage, Avatar } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { fromNow } from "@/utils/time"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DeleteReportedComment, RejectReportedComment } from "./actions"; + +function ViewReportContent({ + comment, + reportContent, + reportId, + reporter, +}: { + reportId: string; + reportContent: string; + reporter: { id: string; username: string; name: string }; + comment: { + id: string; + content: string; + createdAt: Date; + author: { id: string; username: string; name: string }; + }; +}) { + const [loading, setLoading] = useState(false); + return ( + + + + + + + Report + +
+ + {reporter.name} (@{reporter.username}) + + reported {fromNow(comment.createdAt)} +
+
Reason: {reportContent}
+
+
+
+ + + + {comment.author.username!.toUpperCase()} + + +
+
+ + {comment.author.username} + + + + {fromNow(comment.createdAt)} + +
+
{comment.content}
+
+
+
+
+ + + + +
+
+ ); +} + +export default ViewReportContent; diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx index c50b113..ec01d08 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -9,6 +9,7 @@ import { siteConfig } from "@repo/config"; import { ThemeProvider } from "@/components/theme-provider"; import Header from "@/components/header"; import VerifyUser from "@/components/auth"; +import { Toaster } from "sonner"; export const fontSans = FontSans({ subsets: ["latin"], @@ -36,6 +37,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { enableSystem disableTransitionOnChange > +
diff --git a/apps/admin/components/auth/index.tsx b/apps/admin/components/auth/index.tsx index 5d14b64..9001aa2 100644 --- a/apps/admin/components/auth/index.tsx +++ b/apps/admin/components/auth/index.tsx @@ -1,7 +1,7 @@ "use client"; import { assertAdmin } from "@/utils/auth"; import { useSession } from "next-auth/react"; -import { redirect, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useEffect } from "react"; function VerifyUser() { diff --git a/apps/admin/components/header/index.tsx b/apps/admin/components/header/index.tsx index 89ff138..8f039ca 100644 --- a/apps/admin/components/header/index.tsx +++ b/apps/admin/components/header/index.tsx @@ -18,6 +18,14 @@ function Header() { +
diff --git a/apps/admin/components/ui/sonner.tsx b/apps/admin/components/ui/sonner.tsx new file mode 100644 index 0000000..452f4d9 --- /dev/null +++ b/apps/admin/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 29368d5..216fabf 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -7,6 +7,14 @@ const nextConfig = { } return config; }, + async rewrites() { + return [ + { + source: "/github-avatar/:username", + destination: "https://github.com/:username.png", + }, + ]; + }, }; module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json index 24c7d37..acf49ae 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -18,8 +18,10 @@ "@repo/auth": "workspace:^", "@repo/config": "workspace:^", "@repo/db": "workspace:^", + "@repo/utils": "workspace:^", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "dayjs": "^1.11.10", "lucide-react": "^0.303.0", "next": "14.0.4", "next-auth": "5.0.0-beta.4", @@ -27,6 +29,7 @@ "react": "^18", "react-dom": "^18", "react-icons": "^4.12.0", + "sonner": "^1.3.1", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" diff --git a/apps/admin/utils/time.ts b/apps/admin/utils/time.ts new file mode 100644 index 0000000..bbf4bae --- /dev/null +++ b/apps/admin/utils/time.ts @@ -0,0 +1,7 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(relativeTime); + +export const fromNow = (date: string | Date, withoutSuffix?: boolean) => + dayjs(date).fromNow(withoutSuffix); diff --git a/apps/web/utils/index.ts b/apps/web/utils/index.ts index 3269cce..8ddd53d 100644 --- a/apps/web/utils/index.ts +++ b/apps/web/utils/index.ts @@ -1,10 +1 @@ -export const upperFirst = (str: string) => - str.charAt(0).toUpperCase() + str.slice(1); - -export const parseToNumber = (val: string, d: number) => { - const parsed = parseInt(val); - if (!isNaN(parsed)) { - return parsed; - } - return d; -}; +export { upperFirst, parseToNumber } from "@repo/utils"; diff --git a/packages/utils/package.json b/packages/utils/package.json index 1698320..232b2da 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -14,6 +14,7 @@ "dependencies": { "@repo/config": "workspace:^", "clsx": "^2.0.0", + "dayjs": "^1.11.10", "tailwind-merge": "^2.2.0", "typescript": "^5.3.3" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ecfcd5e..0104e6d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,2 +1,3 @@ export { debounce } from "./debounce"; -export { cn } from "./tailwind"; \ No newline at end of file +export { cn } from "./tailwind"; +export { parseToNumber, upperFirst } from "./text"; \ No newline at end of file diff --git a/packages/utils/src/text.ts b/packages/utils/src/text.ts new file mode 100644 index 0000000..3269cce --- /dev/null +++ b/packages/utils/src/text.ts @@ -0,0 +1,10 @@ +export const upperFirst = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); + +export const parseToNumber = (val: string, d: number) => { + const parsed = parseInt(val); + if (!isNaN(parsed)) { + return parsed; + } + return d; +}; diff --git a/packages/utils/src/time.ts b/packages/utils/src/time.ts new file mode 100644 index 0000000..bbf4bae --- /dev/null +++ b/packages/utils/src/time.ts @@ -0,0 +1,7 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(relativeTime); + +export const fromNow = (date: string | Date, withoutSuffix?: boolean) => + dayjs(date).fromNow(withoutSuffix); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7a0a29..97ae806 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,12 +44,18 @@ importers: '@repo/db': specifier: workspace:^ version: link:../../packages/db + '@repo/utils': + specifier: workspace:^ + version: link:../../packages/utils class-variance-authority: specifier: ^0.7.0 version: 0.7.0 clsx: specifier: ^2.0.0 version: 2.0.0 + dayjs: + specifier: ^1.11.10 + version: 1.11.10 lucide-react: specifier: ^0.303.0 version: 0.303.0(react@18.2.0) @@ -71,6 +77,9 @@ importers: react-icons: specifier: ^4.12.0 version: 4.12.0(react@18.2.0) + sonner: + specifier: ^1.3.1 + version: 1.3.1(react-dom@18.2.0)(react@18.2.0) tailwind-merge: specifier: ^2.2.0 version: 2.2.0 @@ -480,6 +489,9 @@ importers: clsx: specifier: ^2.0.0 version: 2.0.0 + dayjs: + specifier: ^1.11.10 + version: 1.11.10 tailwind-merge: specifier: ^2.2.0 version: 2.2.0