From 7997f936b6746387fe7f62bbcc8d0da8fdd60351 Mon Sep 17 00:00:00 2001 From: jpbuizon Date: Sat, 4 Apr 2026 03:29:19 +0800 Subject: [PATCH 1/5] feat: admin diffs --- apps/web/app/admin/page.tsx | 1630 +++++++++-------- apps/web/components/DiffsModal.tsx | 275 +++ apps/web/types/pins.ts | 4 + .../repositories/modification.repository.ts | 134 +- 4 files changed, 1183 insertions(+), 860 deletions(-) create mode 100644 apps/web/components/DiffsModal.tsx create mode 100644 apps/web/types/pins.ts diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 31f034c..d8338bd 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -7,152 +7,155 @@ import { trpc } from "@/lib/trpc"; import { PIN_CATEGORIES, getPinColor } from "@/data/pin-categories"; import { useTheme } from "@/lib/ThemeContext"; import Link from "next/link"; +import { PinDiffType } from "@/types/pins"; +import { DiffsModal } from "@/components/DiffsModal"; export default function AdminDashboard() { - const router = useRouter(); - - const { data, isLoading } = trpc.user.getCurrent.useQuery(); - - const { data: pendingModifications } = - trpc.modification.getPending.useQuery(); - - const { data: pinCounts } = trpc.pin.getStatusCounts.useQuery(); - const { data: userCount } = trpc.user.getCount.useQuery(); - const { data: commentCount } = trpc.comment.getCount.useQuery(); - const { data: pendingPins } = trpc.pin.getAllAdmin.useQuery({ - status: "PENDING_VERIFICATION", - }); - - const { data: activePins } = trpc.pin.getAllAdmin.useQuery({ - status: "ACTIVE", - limit: 5, - }); - const utils = trpc.useUtils(); - const rejectPin = trpc.pin.reject.useMutation({ - onSuccess: () => { - utils.pin.getAllAdmin.invalidate(); - utils.modification.getPending.invalidate(); - }, - }); - const approvePin = trpc.pin.approve.useMutation({ - onSuccess: () => { - utils.pin.getAll.invalidate(); - utils.pin.getAllAdmin.invalidate(); - utils.modification.getPending.invalidate(); - }, - }); - const applyMod = trpc.pin.applyUpdate.useMutation({ - onSuccess: () => { - utils.modification.getPending.invalidate(); - utils.pin.getAll.invalidate(); - utils.pin.getAllAdmin.invalidate(); - utils.pin.getStatusCounts.invalidate(); - }, - }); - const rejectMod = trpc.pin.rejectUpdate.useMutation({ - onSuccess: () => { - utils.modification.getPending.invalidate(); - }, - }); - - const deletePin = trpc.pin.adminDelete.useMutation({ - onSuccess: () => { - utils.pin.getAllAdmin.invalidate(); - }, - }); - - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const { theme, toggleTheme } = useTheme(); - - const [isDeletingPin, setIsDeletingPin] = useState(false); - - const scrollToSection = (sectionId: string) => { - const element = document.getElementById(sectionId); - if (element) { - element.scrollIntoView({ behavior: "smooth", block: "start" }); - if (window.innerWidth <= 768) setIsSidebarOpen(false); - } - }; - - const goToMap = () => router.push("/"); - - const handleSignOut = async () => { - await signOut(); - router.refresh(); - }; - - const activePinCount = pinCounts?.ACTIVE || 0; - const rejectedPinCount = pinCounts?.ARCHIVED || 0; - const pendingPinCount = pinCounts?.PENDING_VERIFICATION || 0; - const totalPins = activePinCount + rejectedPinCount + pendingPinCount; - - const pinTagCounts = { - academic: 0, - food: 0, - transit: 0, - utility: 0, - social: 0, - }; - - const globalVerificationRate = useMemo( - () => Math.round((activePinCount / (totalPins || 1)) * 100) || 0, - [activePinCount, totalPins], - ); - - const globalUserStats = { - totalUsers: userCount, - totalComments: commentCount, - // avgPins: 3.6, - // avgComments: 5.3, - // newUsers7Days: 14, - // newUsers30Days: 45, - }; - - return ( -
- {/* --- MOBILE OVERLAY --- */} - {isSidebarOpen && ( - // biome-ignore lint/a11y/noStaticElementInteractions: - // biome-ignore lint/a11y/useKeyWithClickEvents: -
setIsSidebarOpen(false)} - /> - )} - - {/* --- SIDEBAR --- */} - - -
- {/* --- HEADER --- */} -
-
- -

- Admin Dashboard -

-
-
- - {/* --- MAIN --- */} -
-
-
-

- {isLoading - ? "LOADING..." - : `Welcome, ${data?.name ? data.name.toUpperCase() : "ADMIN"}!`} -

-

- You have accessed the restricted area. 🚨 -

-
- -
-
-

- OVERVIEW -

-
-
-
-

- OVERALL PIN STATISTICS -

-
-
-
-
- - TOTAL PINS - - - {totalPins} - -
-
- - PENDING PINS - - - {pendingPinCount} - -
-
- -
-
- - GLOBAL VERIFICATION - - - {globalVerificationRate}% - -
-
-
-
-
- - {activePinCount} VERIFIED - - - {pendingPinCount} PENDING - - - {rejectedPinCount} REJECTED - -
-
- -
-
- {PIN_CATEGORIES.map((category) => { - const count = - pinTagCounts[ - category.id as keyof typeof pinTagCounts - ] || 0; - const percentage = - totalPins > 0 - ? (count / (totalPins || 1)) * 100 - : 0; - - return ( -
-
- - {category.label} - - - {count} - -
-
-
-
-
- ); - })} -
-
-
-
- -
-
-

- OVERALL USER STATISTICS -

-
-
-
-
- - TOTAL USERS - - - {globalUserStats.totalUsers} - -
-
- - TOTAL COMMENTS - - - {globalUserStats.totalComments} - -
- {/*
+ +
+ +
+ + DISPLAY SETTINGS + + +
+ + +
+ +
+ + +
+ {/* --- HEADER --- */} +
+
+ +

+ Admin Dashboard +

+
+
+ + {/* --- MAIN --- */} +
+
+
+

+ {isLoading + ? "LOADING..." + : `Welcome, ${data?.name ? data.name.toUpperCase() : "ADMIN"}!`} +

+

+ You have accessed the restricted area. 🚨 +

+
+ +
+
+

+ OVERVIEW +

+
+
+
+

+ OVERALL PIN STATISTICS +

+
+
+
+
+ + TOTAL PINS + + + {totalPins} + +
+
+ + PENDING PINS + + + {pendingPinCount} + +
+
+ +
+
+ + GLOBAL VERIFICATION + + + {globalVerificationRate}% + +
+
+
+
+
+ + {activePinCount} VERIFIED + + + {pendingPinCount} PENDING + + + {rejectedPinCount} REJECTED + +
+
+ +
+
+ {PIN_CATEGORIES.map((category) => { + const count = + pinTagCounts[ + category.id as keyof typeof pinTagCounts + ] || 0; + const percentage = + totalPins > 0 + ? (count / (totalPins || 1)) * 100 + : 0; + + return ( +
+
+ + {category.label} + + + {count} + +
+
+
+
+
+ ); + })} +
+
+
+
+ +
+
+

+ OVERALL USER STATISTICS +

+
+
+
+
+ + TOTAL USERS + + + {globalUserStats.totalUsers} + +
+
+ + TOTAL COMMENTS + + + {globalUserStats.totalComments} + +
+ {/*
AVERAGE PINS / USER @@ -460,395 +463,430 @@ export default function AdminDashboard() { {globalUserStats.newUsers30Days}
*/} -
-
-
-
-
- -
-

- PIN MANAGEMENT -

-
-
-
-

- PENDING PIN VERIFICATIONS -

-
- -
-
- {pendingPins?.map((pin) => { - const color = getPinColor( - pin.pinTags?.[0]?.tag.title || "", - ); - return ( -
-
-
- - {pin.title.charAt(0).toUpperCase()} - -
- -
- - {pin.title} - - - By {pin.owner} • {pin.latitude.toFixed(4)},{" "} - {pin.longitude.toFixed(4)} - -
-
- -
- - - - - - - - - -
-
- ); - })} -
-
-
- -
-
-

- RECENTLY VERIFIED PINS -

-
- -
-
- {activePins?.map((pin) => { - const color = getPinColor( - pin.pinTags?.[0]?.tag.title || "", - ); - return ( -
-
-
- - {pin.title.charAt(0).toUpperCase()} - -
- -
- - {pin.title} - - - By {pin.owner} • {pin.latitude.toFixed(4)},{" "} - {pin.longitude.toFixed(4)} - -
-
- -
- - - - - - {isDeletingPin ? ( - <> - - - - - ) : ( - - )} -
-
- ); - })} -
-
-
- -
-
-

- RECENT MODIFICATION REQUESTS -

-
- -
-
- {pendingModifications?.map((mod) => { - const color = "var(--text-primary)"; - return ( -
-
-
- - {mod.pin.title.charAt(0).toUpperCase()} - -
- -
- - {mod.pin.title} - - - Modification by {mod.user.name} - -
-
- -
- - - - - - - - - -
-
- ); - })} -
-
-
-
-
- - {/*
+
+
+
+
+
+ +
+

+ PIN MANAGEMENT +

+
+
+
+

+ PENDING PIN VERIFICATIONS +

+
+ +
+
+ {pendingPins?.map((pin) => { + const color = getPinColor( + pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {pin.title} + + + By {pin.owner} • {pin.latitude.toFixed(4)},{" "} + {pin.longitude.toFixed(4)} + +
+
+ +
+ + + + + + + + + +
+
+ ); + })} +
+
+
+ +
+
+

+ RECENTLY VERIFIED PINS +

+
+ +
+
+ {activePins?.map((pin) => { + const color = getPinColor( + pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {pin.title} + + + By {pin.owner} • {pin.latitude.toFixed(4)},{" "} + {pin.longitude.toFixed(4)} + +
+
+ +
+ + + + + + {isDeletingPin ? ( + <> + + + + + ) : ( + + )} +
+
+ ); + })} +
+
+
+ +
+
+

+ RECENT MODIFICATION REQUESTS +

+
+ +
+
+ {pendingModifications?.map((mod) => { + const color = "var(--text-primary)"; + return ( +
+
+
+ + {mod.pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {mod.pin.title} + + + Modification by {mod.user.name} + +
+
+ {diffs && ( + setDiffs(undefined)} + current={mod.pin} + diffs={diffs} + /> + )} +
+ + + + + + + + + + +
+
+ ); + })} +
+
+
+
+
+ + {/*

USER MANAGEMENT

@@ -954,10 +992,10 @@ export default function AdminDashboard() {
*/} -
-
-
-
-
- ); +
+ + + + + ); } diff --git a/apps/web/components/DiffsModal.tsx b/apps/web/components/DiffsModal.tsx new file mode 100644 index 0000000..fa7ce2c --- /dev/null +++ b/apps/web/components/DiffsModal.tsx @@ -0,0 +1,275 @@ +"use client"; + +import type { PinRouterOutputs } from "@repo/api"; +import type { PinDiffType } from "@/types/pins"; +import { getPinColor } from "@/data/pin-categories"; +import { clsxm } from "@repo/ui/clsxm"; + +interface IDiffsModal { + current: PinRouterOutputs["getSimpleById"]; + diffs: PinDiffType; + onCancel: () => void; +} + +type DiffChunk = + | { type: "same"; value: string } + | { type: "removed"; value: string } + | { type: "added"; value: string }; + +function diffStrings(before: string, after: string): DiffChunk[] { + const a = before.split(" "); + const b = after.split(" "); + const m = a.length; + const n = b.length; + + // Build LCS table + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0), + ); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i]![j] = + a[i - 1] === b[j - 1] + ? dp[i - 1]![j - 1]! + 1 + : Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); + } + } + + // Backtrack to build chunks + const chunks: DiffChunk[] = []; + let i = m, + j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + chunks.push({ type: "same", value: a[i - 1] as string }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) { + chunks.push({ type: "added", value: b[j - 1] as string }); + j--; + } else { + chunks.push({ type: "removed", value: a[i - 1] as string }); + i--; + } + } + + return chunks.reverse(); +} + +export function DiffsModal({ current, diffs, onCancel }: IDiffsModal) { + if (!(current && diffs)) return; + + // THIS IS FOR THE FUTURE DO NOT DELETE: + // // in the current tags as well as in the diffs (to be toggled OFF) + // const removedTags = current.pinTags + // .map((pt) => pt.tag) + // .filter((t) => diffs.tags.includes(t.id)); + + // // in the diffs but not in the current tags (to be toggled ON) + // const newTags = diffs.tags.filter( + // (t) => !current.pinTags.map((pt) => pt.tag.id).includes(t), + // ); + + // NOTE TO FUTURE ME: WE NEED TO CHANGE HOW TAGS ARE ADDED TO THE after FIELD!! + // IF A TAG ID IS IN THE ARRAY, IT MUST BE TOGGLED. NOT JUST ADDED. THIS ALLOWS TAGS TO BE REMOVED!! + + // another note, since we only have 1 tag for now we can + // just change the way we handle it in the future + + const titleDiffs = diffs.data.title + ? diffStrings(current.title, diffs.data.title) + : undefined; + + const descDiffs = diffs.data.description + ? diffStrings(current.description || "", diffs.data.description) + : undefined; + + const currentDesc = descDiffs?.filter((dd) => dd.type !== "added"); + const currentTitle = titleDiffs?.filter((dd) => dd.type !== "added"); + const afterDesc = descDiffs?.filter((dd) => dd.type !== "removed"); + const afterTitle = titleDiffs?.filter((dd) => dd.type !== "removed"); + + return ( +
+
+
+

+ VIEW PIN DIFFERENCES +

+
+ +
+
+ {titleDiffs && ( +
+ + TITLE + +
+ {currentTitle?.map((ct, i) => ( + + {ct.type === "removed" && "- "} + {ct.value} + + ))} +
+
+ )} + + {descDiffs && ( +
+ + DESCRIPTION + +
+ {currentDesc?.map((cd, i) => ( + + {cd.type === "removed" && "- "} + {cd.value} + + ))} +
+
+ )} + + {diffs.tags.length > 0 && ( +
+ + PIN TYPE + +
+ {current.pinTags.map((pt) => { + const t = pt.tag; + const tagColor = getPinColor(t.title); + + return ( +
+ {t.title.toUpperCase()} +
+ ); + })} +
+
+ )} +
+
+
+ {titleDiffs && ( +
+ + TITLE + +
+ {afterTitle?.map((ct, i) => ( + + {ct.type === "added" && "+ "} + {ct.value} + + ))} +
+
+ )} + + {descDiffs && ( +
+ + DESCRIPTION + + +
+ {afterDesc?.map((ad, i) => ( + + {ad.type === "added" && "+ "} + {ad.value} + + ))} +
+
+ )} + + {diffs.tags.length > 0 && ( +
+ + PIN TYPE + +
+ {diffs.tags.map((t) => { + const tagColor = getPinColor(t); + + return ( +
+ {t.toUpperCase()} +
+ ); + })} +
+
+ )} +
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/types/pins.ts b/apps/web/types/pins.ts new file mode 100644 index 0000000..8ae7faa --- /dev/null +++ b/apps/web/types/pins.ts @@ -0,0 +1,4 @@ +export type PinDiffType = { + data: { title?: string; description?: string }; + tags: string[]; +}; diff --git a/packages/db/src/repositories/modification.repository.ts b/packages/db/src/repositories/modification.repository.ts index 9c9848a..9e2ccf0 100644 --- a/packages/db/src/repositories/modification.repository.ts +++ b/packages/db/src/repositories/modification.repository.ts @@ -4,79 +4,85 @@ import { modification } from "../db/schema"; import type { Modification, CreateModification } from "../db/types"; export function makeModificationRepository(db: Database) { - async function create( - details: CreateModification, - ): Promise { - const [m] = await db.insert(modification).values(details).returning(); - return m; - } + async function create( + details: CreateModification, + ): Promise { + const [m] = await db.insert(modification).values(details).returning(); + return m; + } - async function getPending() { - return await db.query.modification.findMany({ - where: eq(modification.status, "PENDING"), - with: { - pin: true, - user: true, - }, - }); - } + async function getPending() { + return await db.query.modification.findMany({ + where: eq(modification.status, "PENDING"), + with: { + pin: { + with: { + pinTags: { + with: { tag: true }, + }, + }, + }, + user: true, + }, + }); + } - async function getById(id: string) { - return await db.query.modification.findFirst({ - where: eq(modification.id, id), - }); - } + async function getById(id: string) { + return await db.query.modification.findFirst({ + where: eq(modification.id, id), + }); + } - async function getByPinId(id: string) { - const query = await db.query.modification.findMany({ - where: and(eq(modification.pinId, id)), - orderBy: desc(modification.createdAt), - with: { - user: true, - reviewer: true, - }, - }); + async function getByPinId(id: string) { + const query = await db.query.modification.findMany({ + where: and(eq(modification.pinId, id)), + orderBy: desc(modification.createdAt), + with: { + user: true, + reviewer: true, + }, + }); - return query.map((q) => { - return { ...q, owner: q.user.name, reviewer: q.reviewer?.name }; - }); - } + return query.map((q) => { + return { ...q, owner: q.user.name, reviewer: q.reviewer?.name }; + }); + } - async function getByUserPinId(userId: string, pinId: string) { - return await db.query.modification.findFirst({ - where: and( - eq(modification.pinId, pinId), - eq(modification.userId, userId), - ), - orderBy: desc(modification.createdAt), - }); - } + async function getByUserPinId(userId: string, pinId: string) { + return await db.query.modification.findFirst({ + where: and( + eq(modification.pinId, pinId), + eq(modification.userId, userId), + ), + orderBy: desc(modification.createdAt), + }); + } - async function applyModification(id: string, adminId: string) { - return await db - .update(modification) - .set({ status: "APPLIED", reviewedBy: adminId, reviewedAt: new Date() }) - .where(eq(modification.id, id)); - } + async function applyModification(id: string, adminId: string) { + return await db + .update(modification) + .set({ status: "APPLIED", reviewedBy: adminId, reviewedAt: new Date() }) + .where(eq(modification.id, id)); + } - async function rejectModification(id: string, adminId: string) { - return await db - .update(modification) - .set({ status: "REJECTED", reviewedBy: adminId, reviewedAt: new Date() }) - .where(eq(modification.id, id)); - } + async function rejectModification(id: string, adminId: string) { + return await db + .update(modification) + .set({ status: "REJECTED", reviewedBy: adminId, reviewedAt: new Date() }) + .where(eq(modification.id, id)); + } - return { - create, - getPending, - getById, - applyModification, - rejectModification, - getByUserPinId, - getByPinId, - }; + return { + create, + getPending, + getById, + applyModification, + rejectModification, + getByUserPinId, + getByPinId, + }; } export type ModificationRepository = ReturnType< - typeof makeModificationRepository + typeof makeModificationRepository >; From 6c1a9a327618e13e5ec6a93db94df0f78b3c1905 Mon Sep 17 00:00:00 2001 From: jpbuizon Date: Sat, 4 Apr 2026 16:10:58 +0800 Subject: [PATCH 2/5] feat: admin modification apply and reject from modal --- apps/web/app/admin/page.tsx | 114 ++++--------- apps/web/components/DiffsModal.tsx | 252 ++++++++++++++++++----------- 2 files changed, 188 insertions(+), 178 deletions(-) diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index d8338bd..cde321b 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -7,8 +7,9 @@ import { trpc } from "@/lib/trpc"; import { PIN_CATEGORIES, getPinColor } from "@/data/pin-categories"; import { useTheme } from "@/lib/ThemeContext"; import Link from "next/link"; -import { PinDiffType } from "@/types/pins"; +import type { PinDiffType } from "@/types/pins"; import { DiffsModal } from "@/components/DiffsModal"; +import { PinRouterOutputs } from "@repo/api"; export default function AdminDashboard() { const router = useRouter(); @@ -43,19 +44,6 @@ export default function AdminDashboard() { utils.modification.getPending.invalidate(); }, }); - const applyMod = trpc.pin.applyUpdate.useMutation({ - onSuccess: () => { - utils.modification.getPending.invalidate(); - utils.pin.getAll.invalidate(); - utils.pin.getAllAdmin.invalidate(); - utils.pin.getStatusCounts.invalidate(); - }, - }); - const rejectMod = trpc.pin.rejectUpdate.useMutation({ - onSuccess: () => { - utils.modification.getPending.invalidate(); - }, - }); const deletePin = trpc.pin.adminDelete.useMutation({ onSuccess: () => { @@ -67,7 +55,11 @@ export default function AdminDashboard() { const { theme, toggleTheme } = useTheme(); const [isDeletingPin, setIsDeletingPin] = useState(false); + const [modId, setModId] = useState(""); const [diffs, setDiffs] = useState(); + const [current, setCurrent] = useState< + PinRouterOutputs["getSimpleById"] | undefined + >(); const scrollToSection = (sectionId: string) => { const element = document.getElementById(sectionId); @@ -744,6 +736,14 @@ export default function AdminDashboard() { + {diffs && ( + setDiffs(undefined)} + current={current} + diffs={diffs} + modId={modId} + /> + )}
{pendingModifications?.map((mod) => { @@ -778,14 +778,23 @@ export default function AdminDashboard() {
- {diffs && ( - setDiffs(undefined)} - current={mod.pin} - diffs={diffs} - /> - )}
+ + + + + - - - - - - - - -
); diff --git a/apps/web/components/DiffsModal.tsx b/apps/web/components/DiffsModal.tsx index fa7ce2c..e05af66 100644 --- a/apps/web/components/DiffsModal.tsx +++ b/apps/web/components/DiffsModal.tsx @@ -4,11 +4,12 @@ import type { PinRouterOutputs } from "@repo/api"; import type { PinDiffType } from "@/types/pins"; import { getPinColor } from "@/data/pin-categories"; import { clsxm } from "@repo/ui/clsxm"; - +import { trpc } from "@/lib/trpc"; interface IDiffsModal { current: PinRouterOutputs["getSimpleById"]; diffs: PinDiffType; onCancel: () => void; + modId: string; } type DiffChunk = @@ -56,7 +57,25 @@ function diffStrings(before: string, after: string): DiffChunk[] { return chunks.reverse(); } -export function DiffsModal({ current, diffs, onCancel }: IDiffsModal) { +export function DiffsModal({ current, diffs, onCancel, modId }: IDiffsModal) { + const utils = trpc.useUtils(); + + const applyMod = trpc.pin.applyUpdate.useMutation({ + onSuccess: () => { + utils.modification.getPending.invalidate(); + utils.pin.getAll.invalidate(); + utils.pin.getAllAdmin.invalidate(); + utils.pin.getStatusCounts.invalidate(); + onCancel(); + }, + }); + const rejectMod = trpc.pin.rejectUpdate.useMutation({ + onSuccess: () => { + utils.modification.getPending.invalidate(); + onCancel(); + }, + }); + if (!(current && diffs)) return; // THIS IS FOR THE FUTURE DO NOT DELETE: @@ -90,35 +109,49 @@ export function DiffsModal({ current, diffs, onCancel }: IDiffsModal) { const afterTitle = titleDiffs?.filter((dd) => dd.type !== "removed"); return ( -
-
-
-

- VIEW PIN DIFFERENCES -

-
- + // biome-ignore lint/a11y/noStaticElementInteractions: + // biome-ignore lint/a11y/useKeyWithClickEvents: +
{ + onCancel(); + }} + className="cursor-pointer fixed inset-0 w-screen h-screen bg-black/15 backdrop-blur-md flex items-center justify-center z-[200] p-5" + > + {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} +
e.stopPropagation()} + className="w-full max-w-[400px] sm:max-w-[900px] p-6 animate-slide-up tactical-panel flex flex-col gap-5" + >
-
+
+

+ CURRENT PIN DETAILS +

{titleDiffs && (
TITLE
- {currentTitle?.map((ct, i) => ( - - {ct.type === "removed" && "- "} - {ct.value} - - ))} + {currentTitle?.map((ct, i) => { + const isFirstInSequence = + i !== 0 ? currentTitle[i - 1]?.type !== "removed" : true; + return ( + + {ct.type === "removed" && isFirstInSequence && "- "} + {ct.value} + + ); + })}
)} @@ -129,19 +162,24 @@ export function DiffsModal({ current, diffs, onCancel }: IDiffsModal) { DESCRIPTION
- {currentDesc?.map((cd, i) => ( - - {cd.type === "removed" && "- "} - {cd.value} - - ))} + {currentDesc?.map((cd, i) => { + const isFirstInSequence = + i !== 0 ? currentDesc[i - 1]?.type !== "removed" : true; + return ( + + {cd.type === "removed" && isFirstInSequence && "- "} + {cd.value} + + ); + })}
)} @@ -175,99 +213,121 @@ export function DiffsModal({ current, diffs, onCancel }: IDiffsModal) {
)}
-
-
- {titleDiffs && ( -
- - TITLE - -
- {afterTitle?.map((ct, i) => ( +
+

+ MODIFIED PIN DETAILS +

+ {titleDiffs && ( +
+ + TITLE + +
+ {afterTitle?.map((ct, i) => { + const isFirstInSequence = + i !== 0 ? afterTitle[i - 1]?.type !== "added" : true; + return ( - {ct.type === "added" && "+ "} + {ct.type === "added" && isFirstInSequence && "+ "} {ct.value} - ))} -
+ ); + })}
- )} +
+ )} + + {descDiffs && ( +
+ + DESCRIPTION + - {descDiffs && ( -
- - DESCRIPTION - +
+ {afterDesc?.map((ad, i) => { + const isFirstInSequence = + i !== 0 ? afterDesc[i - 1]?.type !== "added" : true; -
- {afterDesc?.map((ad, i) => ( + return ( - {ad.type === "added" && "+ "} + {ad.type === "added" && isFirstInSequence && "+ "} {ad.value} - ))} -
+ ); + })}
- )} +
+ )} - {diffs.tags.length > 0 && ( -
- - PIN TYPE - -
- {diffs.tags.map((t) => { - const tagColor = getPinColor(t); + {diffs.tags.length > 0 && ( +
+ + PIN TYPE + +
+ {diffs.tags.map((t) => { + const tagColor = getPinColor(t); - return ( -
- {t.toUpperCase()} -
- ); - })} -
+ return ( +
+ {t.toUpperCase()} +
+ ); + })}
- )} -
+
+ )}
-
+
- +
+ + +
From 7e151f0fa54c745b072834e548b2ddab346fa331 Mon Sep 17 00:00:00 2001 From: jpbuizon Date: Sun, 5 Apr 2026 15:05:57 +0800 Subject: [PATCH 3/5] feat: user pending mods and delete pending mod --- apps/web/app/dashboard/page.tsx | 1238 +++++++++-------- apps/web/components/DiffsModal.tsx | 44 +- .../api/src/routers/modification.router.ts | 17 +- .../api/src/services/modification.service.ts | 27 +- .../repositories/modification.repository.ts | 29 + 5 files changed, 777 insertions(+), 578 deletions(-) diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index f77df8b..ce9cfc9 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -7,311 +7,326 @@ import { trpc } from "@/lib/trpc"; import { PIN_CATEGORIES, getPinColor } from "@/data/pin-categories"; import { useTheme } from "@/lib/ThemeContext"; import Link from "next/link"; +import { DiffsModal } from "@/components/DiffsModal"; +import type { PinRouterOutputs } from "@repo/api"; +import type { PinDiffType } from "@/types/pins"; export default function Dashboard() { - const router = useRouter(); - const { data, isLoading } = trpc.user.getCurrent.useQuery(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - const { data: userPins } = trpc.pin.getCurrentUsersPins.useQuery(undefined, { - refetchOnWindowFocus: false, - }); - - const { data: userComments } = trpc.comment.getCurrentUsersComments.useQuery( - undefined, - { - refetchOnWindowFocus: false, - }, - ); - - // const [isEditingBio, setIsEditingBio] = useState(false); - // const [bioInput, setBioInput] = useState(""); - const { theme, toggleTheme } = useTheme(); - - // const handleSaveBio = () => { - // // TODO: Hook this up to trpc.user.updateBio.useMutation() - // console.log("Saving new bio:", bioInput); - // setIsEditingBio(false); - // }; - - const goToMap = () => router.push("/"); - const goToAdmin = () => router.push("/admin"); - - const handleSignOut = async () => { - await signOut(); - router.refresh(); - }; - const stats = useMemo(() => { - return { - totalPins: userPins?.filter((p) => p.status !== "DELETED").length, - verifiedPins: userPins?.filter((p) => p.status === "ACTIVE").length, - pendingPins: userPins?.filter((p) => p.status === "PENDING_VERIFICATION") - .length, - rejectedPins: userPins?.filter((p) => p.status === "ARCHIVED").length, - comments: userComments?.length, - categoryBreakdown: { - academic: userPins?.filter( - (p) => - p.pinTags.map((pt) => pt.tag.title).includes("academic") && - p.status !== "DELETED", - ).length, - food: userPins?.filter( - (p) => - p.pinTags.map((pt) => pt.tag.title).includes("food") && - p.status !== "DELETED", - ).length, - social: userPins?.filter( - (p) => - p.pinTags.map((pt) => pt.tag.title).includes("social") && - p.status !== "DELETED", - ).length, - utility: userPins?.filter( - (p) => - p.pinTags.map((pt) => pt.tag.title).includes("utility") && - p.status !== "DELETED", - ).length, - transit: userPins?.filter( - (p) => - p.pinTags.map((pt) => pt.tag.title).includes("transit") && - p.status !== "DELETED", - ).length, - }, - pendingList: userPins - ?.filter((p) => p.status === "PENDING_VERIFICATION") - .slice(0, 5), - recentList: userPins?.filter((p) => p.status === "ACTIVE").slice(0, 5), - }; - }, [userPins, userComments]); - - const verificationRate = - stats.verifiedPins && stats.totalPins - ? Math.round((stats.verifiedPins / stats.totalPins) * 100) - : 0; - - return ( -
- {/* --- MOBILE OVERLAY --- */} - {isSidebarOpen && ( - // biome-ignore lint/a11y/noStaticElementInteractions: - // biome-ignore lint/a11y/useKeyWithClickEvents: -
setIsSidebarOpen(false)} - /> - )} - - {/* --- SIDEBAR --- */} - - -
- {/* --- HEADER --- */} -
-
- -

- User Dashboard -

-
-
- - {/* --- MAIN --- */} -
-
-
-

- {isLoading - ? "LOADING..." - : `Welcome, ${data?.name ? data.name.toUpperCase() : "UNKNOWN"}!`} -

-

- You made it! -

-
- - {/* --- DASHBOARD GRID --- */} -
-
-
-

- YOUR PROFILE -

- - {data?.userRole === "admin" ? "ADMIN" : "REGULAR USER"} - -
- -
-
-
-
- {data?.name ? data.name.charAt(0).toUpperCase() : "O"} -
-
-
- - {data?.name || "UNKNOWN NAME"} - - - {(data as { email?: string })?.email || "UNKNOWN EMAIL"} - -
-
- - {/*
+ const [modId, setModId] = useState(""); + const [diffs, setDiffs] = useState(); + const [current, setCurrent] = useState< + PinRouterOutputs["getSimpleById"] | undefined + >(); + + const router = useRouter(); + const { data, isLoading } = trpc.user.getCurrent.useQuery(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const { data: userMods } = trpc.modification.getPendingByUser.useQuery( + undefined, + { refetchOnWindowFocus: false }, + ); + + const { data: userPins } = trpc.pin.getCurrentUsersPins.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + + const { data: userComments } = trpc.comment.getCurrentUsersComments.useQuery( + undefined, + { + refetchOnWindowFocus: false, + }, + ); + + // const [isEditingBio, setIsEditingBio] = useState(false); + // const [bioInput, setBioInput] = useState(""); + const { theme, toggleTheme } = useTheme(); + + // const handleSaveBio = () => { + // // TODO: Hook this up to trpc.user.updateBio.useMutation() + // console.log("Saving new bio:", bioInput); + // setIsEditingBio(false); + // }; + + const goToMap = () => router.push("/"); + const goToAdmin = () => router.push("/admin"); + + const handleSignOut = async () => { + await signOut(); + router.refresh(); + }; + const stats = useMemo(() => { + return { + totalPins: userPins?.filter((p) => p.status !== "DELETED").length, + verifiedPins: userPins?.filter((p) => p.status === "ACTIVE").length, + pendingPins: userPins?.filter((p) => p.status === "PENDING_VERIFICATION") + .length, + rejectedPins: userPins?.filter((p) => p.status === "ARCHIVED").length, + comments: userComments?.length, + categoryBreakdown: { + academic: userPins?.filter( + (p) => + p.pinTags.map((pt) => pt.tag.title).includes("academic") && + p.status !== "DELETED", + ).length, + food: userPins?.filter( + (p) => + p.pinTags.map((pt) => pt.tag.title).includes("food") && + p.status !== "DELETED", + ).length, + social: userPins?.filter( + (p) => + p.pinTags.map((pt) => pt.tag.title).includes("social") && + p.status !== "DELETED", + ).length, + utility: userPins?.filter( + (p) => + p.pinTags.map((pt) => pt.tag.title).includes("utility") && + p.status !== "DELETED", + ).length, + transit: userPins?.filter( + (p) => + p.pinTags.map((pt) => pt.tag.title).includes("transit") && + p.status !== "DELETED", + ).length, + }, + pendingList: userPins?.filter((p) => p.status === "PENDING_VERIFICATION"), + recentList: userPins?.filter((p) => p.status === "ACTIVE").slice(0, 5), + pendingModifications: userMods?.filter((m) => m.status === "PENDING"), + appliedModifications: userMods?.filter((m) => m.status === "APPLIED"), + rejectedModifications: userMods?.filter((m) => m.status === "REJECTED"), + }; + }, [userPins, userComments, userMods]); + + const verificationRate = + stats.verifiedPins && stats.totalPins + ? Math.round((stats.verifiedPins / stats.totalPins) * 100) + : 0; + + return ( +
+ {/* --- MOBILE OVERLAY --- */} + {isSidebarOpen && ( + // biome-ignore lint/a11y/noStaticElementInteractions: + // biome-ignore lint/a11y/useKeyWithClickEvents: +
setIsSidebarOpen(false)} + /> + )} + + {/* --- SIDEBAR --- */} + + +
+ {/* --- HEADER --- */} +
+
+ +

+ User Dashboard +

+
+
+ + {/* --- MAIN --- */} +
+
+
+

+ {isLoading + ? "LOADING..." + : `Welcome, ${data?.name ? data.name.toUpperCase() : "UNKNOWN"}!`} +

+

+ You made it! +

+
+ + {/* --- DASHBOARD GRID --- */} +
+
+
+

+ YOUR PROFILE +

+ + {data?.userRole === "admin" ? "ADMIN" : "REGULAR USER"} + +
+ +
+
+
+
+ {data?.name ? data.name.charAt(0).toUpperCase() : "O"} +
+
+
+ + {data?.name || "UNKNOWN NAME"} + + + {(data as { email?: string })?.email || "UNKNOWN EMAIL"} + +
+
+ + {/*
BIO {!isEditingBio && ( @@ -372,256 +387,369 @@ export default function Dashboard() {

)}
*/} -
-
- -
-
-

- YOUR STATISTICS -

-
- -
- {/* Top Stats Grid */} -
-
- - TOTAL PINS ADDED - - - {stats.totalPins} - -
-
- - TOTAL COMMENTS - - - {stats.comments} - -
-
- - {/* Verification Integrity Bar */} -
-
- - VERIFICATIONS - - - {verificationRate}% - -
-
-
-
-
- - {stats.verifiedPins} VERIFIED - - - {stats.pendingPins} PENDING - - - {stats.rejectedPins} REJECTED - -
-
- - {/* Category Distribution */} -
- - CATEGORY DISTRIBUTION - -
- {PIN_CATEGORIES.map((category) => { - const count = - stats.categoryBreakdown[ - category.id as keyof typeof stats.categoryBreakdown - ] || 0; - const percentage = - stats.totalPins && stats.totalPins > 0 - ? (count / stats.totalPins) * 100 - : 0; - - return ( -
-
- - {category.label} - - - {count} - -
-
-
-
-
- ); - })} -
-
-
-
- -
-
-

- YOUR PENDING PINS -

- - {stats.pendingList?.length} - -
-
-
- {stats.pendingList?.map((pin) => { - const color = getPinColor( - pin.pinTags?.[0]?.tag.title || "", - ); - return ( -
-
-
- - {pin.title.charAt(0).toUpperCase()} - -
- -
- - {pin.title} - - - {pin.latitude.toFixed(4)},{" "} - {pin.longitude.toFixed(4)} - -
-
- - - - - - -
- ); - })} -
-
-
- -
-
-

- YOUR RECENT PINS -

- - {stats.recentList?.length} - -
-
-
- {stats.recentList?.map((pin) => { - const color = getPinColor( - pin.pinTags?.[0]?.tag.title || "", - ); - return ( -
-
-
- - {pin.title.charAt(0).toUpperCase()} - -
- -
- - {pin.title} - - - {pin.latitude.toFixed(4)},{" "} - {pin.longitude.toFixed(4)} - -
-
- - - - - - -
- ); - })} -
-
-
-
-
-
-
-
- ); +
+
+ +
+
+

+ YOUR STATISTICS +

+
+ +
+ {/* Top Stats Grid */} +
+
+ + TOTAL PINS ADDED + + + {stats.totalPins} + +
+
+ + TOTAL COMMENTS + + + {stats.comments} + +
+
+ + {/* Verification Integrity Bar */} +
+
+ + VERIFICATIONS + + + {verificationRate}% + +
+
+
+
+
+ + {stats.verifiedPins} VERIFIED + + + {stats.pendingPins} PENDING + + + {stats.rejectedPins} REJECTED + +
+
+ + {/* Category Distribution */} +
+ + CATEGORY DISTRIBUTION + +
+ {PIN_CATEGORIES.map((category) => { + const count = + stats.categoryBreakdown[ + category.id as keyof typeof stats.categoryBreakdown + ] || 0; + const percentage = + stats.totalPins && stats.totalPins > 0 + ? (count / stats.totalPins) * 100 + : 0; + + return ( +
+
+ + {category.label} + + + {count} + +
+
+
+
+
+ ); + })} +
+
+
+
+ +
+
+

+ YOUR PENDING PINS +

+ + {stats.pendingList?.length} + +
+
+
+ {stats.pendingList?.map((pin) => { + const color = getPinColor( + pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {pin.title} + + + {pin.latitude.toFixed(4)},{" "} + {pin.longitude.toFixed(4)} + +
+
+ + + + + + +
+ ); + })} +
+
+
+ +
+
+

+ YOUR RECENT PINS +

+ + {stats.recentList?.length} + +
+
+
+ {stats.recentList?.map((pin) => { + const color = getPinColor( + pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {pin.title} + + + {pin.latitude.toFixed(4)},{" "} + {pin.longitude.toFixed(4)} + +
+
+ + + + + + +
+ ); + })} +
+
+
+ +
+
+

+ YOUR PENDING MODIFICATIONS +

+ + {stats.pendingModifications?.length} + +
+ + {diffs && ( + setDiffs(undefined)} + current={current} + diffs={diffs} + modId={modId} + /> + )} +
+
+ {stats.pendingModifications?.map((mod) => { + const color = getPinColor( + mod.pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {mod.pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {mod.pin.title} + + + {mod.pin.latitude.toFixed(4)},{" "} + {mod.pin.longitude.toFixed(4)} + +
+
+ +
+ + + + + + +
+
+ ); + })} +
+
+
+
+
+
+
+
+ ); } diff --git a/apps/web/components/DiffsModal.tsx b/apps/web/components/DiffsModal.tsx index e05af66..721f446 100644 --- a/apps/web/components/DiffsModal.tsx +++ b/apps/web/components/DiffsModal.tsx @@ -10,6 +10,7 @@ interface IDiffsModal { diffs: PinDiffType; onCancel: () => void; modId: string; + isUser?: boolean; } type DiffChunk = @@ -57,7 +58,13 @@ function diffStrings(before: string, after: string): DiffChunk[] { return chunks.reverse(); } -export function DiffsModal({ current, diffs, onCancel, modId }: IDiffsModal) { +export function DiffsModal({ + current, + diffs, + onCancel, + modId, + isUser = false, +}: IDiffsModal) { const utils = trpc.useUtils(); const applyMod = trpc.pin.applyUpdate.useMutation({ @@ -72,6 +79,15 @@ export function DiffsModal({ current, diffs, onCancel, modId }: IDiffsModal) { const rejectMod = trpc.pin.rejectUpdate.useMutation({ onSuccess: () => { utils.modification.getPending.invalidate(); + utils.modification.getPendingByUser.invalidate(); + onCancel(); + }, + }); + + const userCancelMod = trpc.modification.userCancelMod.useMutation({ + onSuccess: () => { + utils.modification.getPending.invalidate(); + utils.modification.getPendingByUser.invalidate(); onCancel(); }, }); @@ -316,17 +332,25 @@ export function DiffsModal({ current, diffs, onCancel, modId }: IDiffsModal) { - + {!isUser && ( + + )}
diff --git a/packages/api/src/routers/modification.router.ts b/packages/api/src/routers/modification.router.ts index 1be7658..26d1d68 100644 --- a/packages/api/src/routers/modification.router.ts +++ b/packages/api/src/routers/modification.router.ts @@ -1,9 +1,18 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import { adminProcedure, router } from "../trpc"; +import { adminProcedure, router, userProcedure } from "../trpc"; +import z from "zod"; export const modificationRouter = router({ - getPending: adminProcedure.query(async ({ ctx }) => { - return await ctx.services.modification.getAllPending(); - }), + getPending: adminProcedure.query(async ({ ctx }) => { + return await ctx.services.modification.getAllPending(); + }), + getPendingByUser: userProcedure.query(async ({ ctx }) => { + return await ctx.services.modification.getAllPendingByUser(ctx.user.id); + }), + userCancelMod: userProcedure + .input(z.object({ modId: z.string() })) + .mutation(async ({ ctx, input }) => { + return await ctx.services.modification.userCancel(input.modId); + }), }); type ModificationRouter = typeof modificationRouter; diff --git a/packages/api/src/services/modification.service.ts b/packages/api/src/services/modification.service.ts index 411f879..4bccab3 100644 --- a/packages/api/src/services/modification.service.ts +++ b/packages/api/src/services/modification.service.ts @@ -1,17 +1,26 @@ import type { Database, ModificationRepository } from "@repo/db"; export function makeModificationService( - repositories: { modification: ModificationRepository }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - db: Database, + repositories: { modification: ModificationRepository }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + db: Database, ) { - async function getAllPending() { - return await repositories.modification.getPending(); - } + async function getAllPending() { + return await repositories.modification.getPending(); + } - return { - getAllPending, - }; + async function getAllPendingByUser(id: string) { + return await repositories.modification.getByUserId(id); + } + async function userCancel(modId: string) { + return await repositories.modification.deleteModification(modId); + } + + return { + getAllPending, + getAllPendingByUser, + userCancel, + }; } export type ModificationService = ReturnType; diff --git a/packages/db/src/repositories/modification.repository.ts b/packages/db/src/repositories/modification.repository.ts index 9e2ccf0..95a812c 100644 --- a/packages/db/src/repositories/modification.repository.ts +++ b/packages/db/src/repositories/modification.repository.ts @@ -48,6 +48,25 @@ export function makeModificationRepository(db: Database) { }); } + async function getByUserId(id: string) { + const query = await db.query.modification.findMany({ + where: and(eq(modification.userId, id)), + orderBy: desc(modification.createdAt), + with: { + pin: { + with: { + pinTags: { with: { tag: true } }, + }, + }, + reviewer: true, + }, + }); + + return query.map((q) => { + return { ...q, reviewer: q.reviewer?.name }; + }); + } + async function getByUserPinId(userId: string, pinId: string) { return await db.query.modification.findFirst({ where: and( @@ -72,6 +91,14 @@ export function makeModificationRepository(db: Database) { .where(eq(modification.id, id)); } + async function deleteModification(modId: string) { + const d = await db + .delete(modification) + .where(eq(modification.id, modId)) + .returning(); + return !!d; + } + return { create, getPending, @@ -80,6 +107,8 @@ export function makeModificationRepository(db: Database) { rejectModification, getByUserPinId, getByPinId, + getByUserId, + deleteModification, }; } From 64bda42612d434a3fe52dfb03490baec6272c10b Mon Sep 17 00:00:00 2001 From: jpbuizon Date: Sun, 5 Apr 2026 15:20:48 +0800 Subject: [PATCH 4/5] feat: applied and rejected modifications of users --- apps/web/app/dashboard/page.tsx | 218 ++++++++++++++++++++++++++- apps/web/components/DiffsModal.tsx | 228 +++++++++++++++-------------- 2 files changed, 335 insertions(+), 111 deletions(-) diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index ce9cfc9..1393071 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -10,9 +10,11 @@ import Link from "next/link"; import { DiffsModal } from "@/components/DiffsModal"; import type { PinRouterOutputs } from "@repo/api"; import type { PinDiffType } from "@/types/pins"; +import { ModificationRouterOutputs } from "../../../../packages/api/src/routers/modification.router"; export default function Dashboard() { - const [modId, setModId] = useState(""); + const [mod, setMod] = + useState(); const [diffs, setDiffs] = useState(); const [current, setCurrent] = useState< PinRouterOutputs["getSimpleById"] | undefined @@ -644,13 +646,14 @@ export default function Dashboard() {
- {diffs && ( + {diffs && mod && ( setDiffs(undefined)} current={current} diffs={diffs} - modId={modId} + modId={mod.id} /> )}
@@ -718,7 +721,214 @@ export default function Dashboard() { : undefined, ); setCurrent(mod.pin); - setModId(mod.id); + setMod(mod); + }} + > + + + + + + + +
+
+ ); + })} +
+
+ + +
+
+

+ YOUR APPLIED MODIFICATIONS +

+ + {stats.appliedModifications?.length} + +
+ +
+
+ {stats.appliedModifications?.map((mod) => { + const color = getPinColor( + mod.pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {mod.pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {mod.pin.title} + + + {mod.pin.latitude.toFixed(4)},{" "} + {mod.pin.longitude.toFixed(4)} + +
+
+ +
+ + + + + + +
+
+ ); + })} +
+
+
+
+
+

+ YOUR REJECTED MODIFICATIONS +

+ + {stats.rejectedModifications?.length} + +
+ +
+
+ {stats.rejectedModifications?.map((mod) => { + const color = getPinColor( + mod.pin.pinTags?.[0]?.tag.title || "", + ); + return ( +
+
+
+ + {mod.pin.title.charAt(0).toUpperCase()} + +
+ +
+ + {mod.pin.title} + + + {mod.pin.latitude.toFixed(4)},{" "} + {mod.pin.longitude.toFixed(4)} + +
+
+ +
+ + + + + +
- + {!isApplied && ( + + )} {!isUser && ( - ) - ) : ( -
- -
- - -
-
- )} -
- - {comment.replies && comment.replies.length > 0 && ( -
- {comment.replies.map((reply) => ( - - ))} -
- )} -
- ); + const { data: sessionData } = useSession(); + const utils = trpc.useUtils(); + const createComment = trpc.comment.create.useMutation({ + onSuccess() { + utils.pin.getById.invalidate(); + setIsReplying(false); + }, + }); + const formMethods = useForm({ resolver: zodResolver(commentSchema) }); + const [isReplying, setIsReplying] = useState(false); + if (depth > 3) return null; + + function onSubmit(data: commentSchemaType) { + createComment.mutate({ + message: data.message, + pinId: comment.pinId, + parentId: comment.id, + }); + } + + return ( +
0 ? "ml-4 pl-4 border-l-[2px] border-border-color mt-3" : ""}`} + > +
+ + {comment.authorName} + + + {new Date(comment.createdAt).toLocaleString("default")} + +
+ +

+ {comment.message} +

+ +
+ {!isReplying ? ( + sessionData && + depth < 3 && ( + + ) + ) : ( +
+ +
+ + +
+
+ )} +
+ + {comment.replies && comment.replies.length > 0 && ( +
+ {comment.replies.map((reply) => ( + + ))} +
+ )} +
+ ); }; export function ExpandedPinView({ pinId, onClose }: ExpandedPinViewProps) { - const utils = trpc.useUtils(); - const { data: sessionData } = useSession(); - const { data: pin, isLoading: isPinLoading } = trpc.pin.getById.useQuery( - { id: pinId }, - { refetchOnWindowFocus: false }, - ); - - const createComment = trpc.comment.create.useMutation({ - onSuccess() { - utils.pin.getById.invalidate(); - setIsReplying(false); - }, - }); - const deletePin = trpc.pin.userDelete.useMutation({ - onSuccess() { - utils.pin.getAll.invalidate(); - onClose(); - }, - }); - - const formMethods = useForm({ resolver: zodResolver(commentSchema) }); - const [isReplying, setIsReplying] = useState(false); - - function onSubmit(data: commentSchemaType) { - createComment.mutate({ - message: data.message, - pinId: pinId, - }); - } - - const [isDeleting, setIsDeleting] = useState(false); - - function onDelete() { - deletePin.mutate({ id: pinId }); - } - - const color = getPinColor( - pin?.pinTags ? pin.pinTags[0]?.tag.title || "" : "", - ); - - const getStatusDisplay = (status: string | undefined) => { - switch (status) { - case "VERIFIED": - return { - text: "VERIFIED", - // color: "var(--status-success)", - icon: ( - - - - - ), - }; - case "PENDING_VERIFICATION": - return { - text: "PENDING", - // color: "var(--neon-yellow)", - icon: ( - - - - - ), - }; - case "REJECTED": - return { - text: "REJECTED", - // color: "var(--status-danger)", - icon: ( - - - - - - ), - }; - default: - return { - text: status || "UNKNOWN", - color: "var(--text-secondary)", - icon: ( - - - - - - ), - }; - } - }; - - const statusData = getStatusDisplay(pin?.status); - - const [isEditing, setIsEditing] = useState(false); - - if (isPinLoading || !pin) - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: - // biome-ignore lint/a11y/useKeyWithClickEvents: -
-
-
- ); - - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: - // biome-ignore lint/a11y/useKeyWithClickEvents: -
- {/** biome-ignore lint/a11y/noStaticElementInteractions: */} - {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} -
e.stopPropagation()} - > - {isEditing && ( - setIsEditing(false)} - onCancel={() => setIsEditing(false)} - pin={pin} - /> - )} - -
- {/* HEADER */} -
-
- - {pin?.pinTags?.map((pt) => pt.tag.title).join(", ")} - -

- {pin?.title} -

-
- -
- {!!sessionData && ( - - )} - - -
-
- - {/* HORIZONTAL BENTO GALLERY OR FALLBACK */} - {pin?.images && pin.images.length > 0 ? ( -
- {pin.images.map((img) => ( -
- -
- ))} -
- ) : ( -
- - - - - - - NO IMAGES AVAILABLE -
- )} - - {/* BODY: INTEL DASHBOARD */} -
-

- {pin?.description} -

- -
- {/* PIN ID */} -
- - PIN ID - - - {pin?.id?.padStart(7, "0")} - -
- {/* OWNER */} -
- - CREATED BY - - - {pin?.owner} - -
- {/* COORDINATES */} -
- - COORDINATES (LAT, LNG) - - - {pin?.latitude?.toFixed(6)}, {pin?.longitude?.toFixed(6)} - -
- {/* STATUS */} -
- - STATUS - - - {statusData.icon} - {statusData.text} - -
- {/* TIMESTAMPS */} -
- - CREATED AT - - - {new Date(pin?.createdAt || "").toLocaleString("default", { - month: "long", - year: "numeric", - day: "2-digit", - })} - -
- - {pin.modifications.filter((m) => m.status === "APPLIED").length > - 0 && ( -
- - LAST UPDATED - - - {new Date(pin?.updatedAt || "").toLocaleString("default", { - month: "long", - year: "numeric", - day: "2-digit", - })}{" "} - by {pin.modifications[0]?.user.name} - -
- )} -
- - {/* OWNER ACTIONS */} - {!isDeleting && sessionData?.user.id === pin?.ownerId && ( - - )} - - {isDeleting && ( -
-

- Are you sure you want to permanently delete this pin? -

-
- - -
-
- )} - -
-

- FORUM -

- {!isReplying ? ( - sessionData && ( - - ) - ) : ( -
- -
- - -
-
- )} -
- {pin?.comments?.map((thread) => ( - - ))} -
-
-
-
- -
-
-
- ); + const utils = trpc.useUtils(); + const { data: sessionData } = useSession(); + const { data: pin, isLoading: isPinLoading } = trpc.pin.getById.useQuery( + { id: pinId }, + { refetchOnWindowFocus: false }, + ); + + const createComment = trpc.comment.create.useMutation({ + onSuccess() { + utils.pin.getById.invalidate(); + setIsReplying(false); + }, + }); + const deletePin = trpc.pin.userDelete.useMutation({ + onSuccess() { + utils.pin.getAll.invalidate(); + onClose(); + }, + }); + + const formMethods = useForm({ resolver: zodResolver(commentSchema) }); + const [isReplying, setIsReplying] = useState(false); + + function onSubmit(data: commentSchemaType) { + createComment.mutate({ + message: data.message, + pinId: pinId, + }); + } + + const [isDeleting, setIsDeleting] = useState(false); + + function onDelete() { + deletePin.mutate({ id: pinId }); + } + + const color = getPinColor( + pin?.pinTags ? pin.pinTags[0]?.tag.title || "" : "", + ); + + const getStatusDisplay = (status: string | undefined) => { + switch (status) { + case "VERIFIED": + return { + text: "VERIFIED", + // color: "var(--status-success)", + icon: ( + + + + + ), + }; + case "PENDING_VERIFICATION": + return { + text: "PENDING", + // color: "var(--neon-yellow)", + icon: ( + + + + + ), + }; + case "REJECTED": + return { + text: "REJECTED", + // color: "var(--status-danger)", + icon: ( + + + + + + ), + }; + default: + return { + text: status || "UNKNOWN", + color: "var(--text-secondary)", + icon: ( + + + + + + ), + }; + } + }; + + const statusData = getStatusDisplay(pin?.status); + + const [isEditing, setIsEditing] = useState(false); + const [isViewingHistory, setIsViewingHistory] = useState(false); + + if (isPinLoading || !pin) + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: + // biome-ignore lint/a11y/useKeyWithClickEvents: +
+
+
+ ); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: + // biome-ignore lint/a11y/useKeyWithClickEvents: +
+ {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} +
e.stopPropagation()} + > + {isEditing && ( + setIsEditing(false)} + onCancel={() => setIsEditing(false)} + pin={pin} + /> + )} + + {isViewingHistory && ( + setIsViewingHistory(false)} + /> + )} + +
+ {/* HEADER */} +
+
+ + {pin?.pinTags?.map((pt) => pt.tag.title).join(", ")} + +

+ {pin?.title} +

+
+ +
+ {!!sessionData && ( + + )} + + + + +
+
+ + {/* HORIZONTAL BENTO GALLERY OR FALLBACK */} + {pin?.images && pin.images.length > 0 ? ( +
+ {pin.images.map((img) => ( +
+ +
+ ))} +
+ ) : ( +
+ + + + + + + NO IMAGES AVAILABLE +
+ )} + + {/* BODY: INTEL DASHBOARD */} +
+

+ {pin?.description} +

+ +
+ {/* PIN ID */} +
+ + PIN ID + + + {pin?.id?.padStart(7, "0")} + +
+ {/* OWNER */} +
+ + CREATED BY + + + {pin?.owner} + +
+ {/* COORDINATES */} +
+ + COORDINATES (LAT, LNG) + + + {pin?.latitude?.toFixed(6)}, {pin?.longitude?.toFixed(6)} + +
+ {/* STATUS */} +
+ + STATUS + + + {statusData.icon} + {statusData.text} + +
+ {/* TIMESTAMPS */} +
+ + CREATED AT + + + {new Date(pin?.createdAt || "").toLocaleString("default", { + month: "long", + year: "numeric", + day: "2-digit", + })} + +
+ + {pin.modifications.filter((m) => m.status === "APPLIED").length > + 0 && ( +
+ + LAST UPDATED + + + {new Date(pin?.updatedAt || "").toLocaleString("default", { + month: "long", + year: "numeric", + day: "2-digit", + })}{" "} + by {pin.modifications[0]?.user.name} + +
+ )} +
+ + {/* OWNER ACTIONS */} + {!isDeleting && sessionData?.user.id === pin?.ownerId && ( + + )} + + {isDeleting && ( +
+

+ Are you sure you want to permanently delete this pin? +

+
+ + +
+
+ )} + +
+

+ FORUM +

+ {!isReplying ? ( + sessionData && ( + + ) + ) : ( +
+ +
+ + +
+
+ )} +
+ {pin?.comments?.map((thread) => ( + + ))} +
+
+
+
+ +
+
+
+ ); } diff --git a/apps/web/components/HistoryModal.tsx b/apps/web/components/HistoryModal.tsx new file mode 100644 index 0000000..4abcc1f --- /dev/null +++ b/apps/web/components/HistoryModal.tsx @@ -0,0 +1,133 @@ +"use client"; + +import type { ModificationRouterOutputs, PinRouterOutputs } from "@repo/api"; +import type { PinDiffType } from "@/types/pins"; +import { trpc } from "@/lib/trpc"; +import { useMemo, useState } from "react"; +import { DiffsModal } from "./DiffsModal"; +interface IHistoryModal { + pinId: string; + onClose: () => void; +} +export function HistoryModal({ pinId, onClose }: IHistoryModal) { + const [mod, setMod] = + useState(); + const [diffs, setDiffs] = useState(); + const [current, setCurrent] = useState< + PinRouterOutputs["getSimpleById"] | undefined + >(); + const { data: modifications } = trpc.modification.getByPinId.useQuery({ + pinId: pinId, + }); + const parsedMods = useMemo( + () => modifications?.filter((m) => m.status === "APPLIED") || [], + [modifications], + ); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: + // biome-ignore lint/a11y/useKeyWithClickEvents: +
{ + onClose(); + }} + className="cursor-pointer fixed inset-0 w-screen h-screen bg-black/50 backdrop-blur-md flex items-center justify-center z-[200] p-5" + > + {diffs && mod && ( + setDiffs(undefined)} + current={current} + diffs={diffs} + modId={mod.id} + /> + )} + {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} +
e.stopPropagation()} + className="w-full max-w-[500px] p-6 animate-slide-up tactical-panel flex flex-col gap-5" + > +

+ PIN MODIFICATION HISTORY +

+
+ {parsedMods.map((m) => { + const after = m.after as PinDiffType | undefined; + const fields = Object.keys(after?.data || {}); + if ((after?.tags.length || 0) > 0) fields.push("tags"); + return ( +
+
+
+
+ Modified: + {fields.join(", ")} +
+
+ By: + {m.owner} +
+
+ + +
+
+
+ Applied On: + {new Date(m.reviewedAt || "").toLocaleString("default", { + day: "numeric", + month: "long", + year: "numeric", + })} +
+
+ Reviewer: + {m.reviewer} +
+
+
+ ); + })} +
+ +
+
+ ); +} diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 04ffe7a..6b7fcfb 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -6,13 +6,17 @@ import { commentRouter } from "./comment.router"; import { modificationRouter } from "./modification.router"; export const appRouter = router({ - user: userRouter, - pin: pinRouter, - tag: tagRouter, - comment: commentRouter, - modification: modificationRouter, + user: userRouter, + pin: pinRouter, + tag: tagRouter, + comment: commentRouter, + modification: modificationRouter, }); export type AppRouter = typeof appRouter; export type { PinRouterOutputs, PinRouterInputs } from "./pins.router"; +export type { + ModificationRouterOutputs, + ModificationRouterInputs, +} from "./modification.router"; diff --git a/packages/api/src/routers/modification.router.ts b/packages/api/src/routers/modification.router.ts index 26d1d68..289db72 100644 --- a/packages/api/src/routers/modification.router.ts +++ b/packages/api/src/routers/modification.router.ts @@ -1,5 +1,10 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import { adminProcedure, router, userProcedure } from "../trpc"; +import { + adminProcedure, + publicProcedure, + router, + userProcedure, +} from "../trpc"; import z from "zod"; export const modificationRouter = router({ getPending: adminProcedure.query(async ({ ctx }) => { @@ -13,6 +18,11 @@ export const modificationRouter = router({ .mutation(async ({ ctx, input }) => { return await ctx.services.modification.userCancel(input.modId); }), + getByPinId: publicProcedure + .input(z.object({ pinId: z.string() })) + .query(async ({ ctx, input }) => { + return await ctx.services.modification.getByPinId(input.pinId); + }), }); type ModificationRouter = typeof modificationRouter; diff --git a/packages/api/src/services/modification.service.ts b/packages/api/src/services/modification.service.ts index 4bccab3..abd6da7 100644 --- a/packages/api/src/services/modification.service.ts +++ b/packages/api/src/services/modification.service.ts @@ -16,10 +16,15 @@ export function makeModificationService( return await repositories.modification.deleteModification(modId); } + async function getByPinId(pinId: string) { + return await repositories.modification.getByPinId(pinId); + } + return { getAllPending, getAllPendingByUser, userCancel, + getByPinId, }; } diff --git a/packages/db/src/repositories/modification.repository.ts b/packages/db/src/repositories/modification.repository.ts index 95a812c..e261f29 100644 --- a/packages/db/src/repositories/modification.repository.ts +++ b/packages/db/src/repositories/modification.repository.ts @@ -1,6 +1,6 @@ import { and, desc, eq } from "drizzle-orm"; import type { Database } from "../db/database"; -import { modification } from "../db/schema"; +import { modification, pinTags } from "../db/schema"; import type { Modification, CreateModification } from "../db/types"; export function makeModificationRepository(db: Database) { @@ -36,8 +36,9 @@ export function makeModificationRepository(db: Database) { async function getByPinId(id: string) { const query = await db.query.modification.findMany({ where: and(eq(modification.pinId, id)), - orderBy: desc(modification.createdAt), + orderBy: desc(modification.reviewedAt), with: { + pin: { with: { pinTags: { with: { tag: true } } } }, user: true, reviewer: true, },