diff --git a/apps/web/package.json b/apps/web/package.json index 0881f048..4aff4af5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-switch": "^1.0.3", "@react-email/components": "^0.0.21", "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^5.51.11", "@tanstack/react-table": "^8.19.3", "@trpc/client": "11.0.0-rc.466", diff --git a/apps/web/public/img/dash/pass/bg.png b/apps/web/public/img/dash/pass/bg.png deleted file mode 100644 index 646062c6..00000000 Binary files a/apps/web/public/img/dash/pass/bg.png and /dev/null differ diff --git a/apps/web/public/img/dash/pass/bg.webp b/apps/web/public/img/dash/pass/bg.webp new file mode 100644 index 00000000..66a2c4f7 Binary files /dev/null and b/apps/web/public/img/dash/pass/bg.webp differ diff --git a/apps/web/src/actions/admin/modify-nav-item.ts b/apps/web/src/actions/admin/modify-nav-item.ts index 5dc3a48e..5d3c3a21 100644 --- a/apps/web/src/actions/admin/modify-nav-item.ts +++ b/apps/web/src/actions/admin/modify-nav-item.ts @@ -2,12 +2,18 @@ import { z } from "zod"; import { adminAction } from "@/lib/safe-action"; -import { kv } from "@vercel/kv"; +import { redisSAdd, redisHSet, removeNavItem } from "@/lib/utils/server/redis"; import { revalidatePath } from "next/cache"; +import { kv } from "@vercel/kv"; const metadataSchema = z.object({ name: z.string().min(1), - url: z.string(), + url: z.string().min(1), +}); + +const editMetadataSchema = metadataSchema.extend({ + existingName: z.string().min(1), + enabled: z.boolean(), }); // Maybe a better way to do this for revalidation? Who knows. @@ -16,12 +22,34 @@ const navAdminPage = "/admin/toggles/landing"; export const setItem = adminAction .schema(metadataSchema) .action(async ({ parsedInput: { name, url }, ctx: { user, userId } }) => { - await kv.sadd("config:navitemslist", encodeURIComponent(name)); - await kv.hset(`config:navitems:${encodeURIComponent(name)}`, { + await redisSAdd("config:navitemslist", encodeURIComponent(name)); + await redisHSet(`config:navitems:${encodeURIComponent(name)}`, { + url, + name, + enabled: true, + }); + revalidatePath(navAdminPage); + return { success: true }; + }); + +export const editItem = adminAction + .schema(editMetadataSchema) + .action(async ({ parsedInput: { name, url, existingName } }) => { + const pipe = kv.pipeline(); + + if (existingName != name) { + pipe.srem("config:navitemslist", encodeURIComponent(existingName)); + } + + pipe.sadd("config:navitemslist", encodeURIComponent(name)); + pipe.hset(`config:navitems:${encodeURIComponent(name)}`, { url, name, enabled: true, }); + + await pipe.exec(); + revalidatePath(navAdminPage); return { success: true }; }); @@ -29,10 +57,7 @@ export const setItem = adminAction export const removeItem = adminAction .schema(z.string()) .action(async ({ parsedInput: name, ctx: { user, userId } }) => { - const pipe = kv.pipeline(); - pipe.srem("config:navitemslist", encodeURIComponent(name)); - pipe.del(`config:navitems:${encodeURIComponent(name)}`); - await pipe.exec(); + await removeNavItem(name); // await new Promise((resolve) => setTimeout(resolve, 1500)); revalidatePath(navAdminPage); return { success: true }; @@ -45,7 +70,7 @@ export const toggleItem = adminAction parsedInput: { name, statusToSet }, ctx: { user, userId }, }) => { - await kv.hset(`config:navitems:${encodeURIComponent(name)}`, { + await redisHSet(`config:navitems:${encodeURIComponent(name)}`, { enabled: statusToSet, }); revalidatePath(navAdminPage); diff --git a/apps/web/src/actions/admin/registration-actions.ts b/apps/web/src/actions/admin/registration-actions.ts index a3c52211..1d069199 100644 --- a/apps/web/src/actions/admin/registration-actions.ts +++ b/apps/web/src/actions/admin/registration-actions.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { adminAction } from "@/lib/safe-action"; -import { kv } from "@vercel/kv"; +import { redisSet } from "@/lib/utils/server/redis"; import { revalidatePath } from "next/cache"; const defaultRegistrationToggleSchema = z.object({ @@ -16,7 +16,7 @@ const defaultRSVPLimitSchema = z.object({ export const toggleRegistrationEnabled = adminAction .schema(defaultRegistrationToggleSchema) .action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => { - await kv.set("config:registration:registrationEnabled", enabled); + await redisSet("config:registration:registrationEnabled", enabled); revalidatePath("/admin/toggles/registration"); return { success: true, statusSet: enabled }; }); @@ -24,7 +24,10 @@ export const toggleRegistrationEnabled = adminAction export const toggleRegistrationMessageEnabled = adminAction .schema(defaultRegistrationToggleSchema) .action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => { - await kv.set("config:registration:registrationMessageEnabled", enabled); + await redisSet( + "config:registration:registrationMessageEnabled", + enabled, + ); revalidatePath("/admin/toggles/registration"); return { success: true, statusSet: enabled }; }); @@ -32,7 +35,10 @@ export const toggleRegistrationMessageEnabled = adminAction export const toggleSecretRegistrationEnabled = adminAction .schema(defaultRegistrationToggleSchema) .action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => { - await kv.set("config:registration:secretRegistrationEnabled", enabled); + await redisSet( + "config:registration:secretRegistrationEnabled", + enabled, + ); revalidatePath("/admin/toggles/registration"); return { success: true, statusSet: enabled }; }); @@ -40,7 +46,7 @@ export const toggleSecretRegistrationEnabled = adminAction export const toggleRSVPs = adminAction .schema(defaultRegistrationToggleSchema) .action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => { - await kv.set("config:registration:allowRSVPs", enabled); + await redisSet("config:registration:allowRSVPs", enabled); revalidatePath("/admin/toggles/registration"); return { success: true, statusSet: enabled }; }); @@ -48,7 +54,7 @@ export const toggleRSVPs = adminAction export const setRSVPLimit = adminAction .schema(defaultRSVPLimitSchema) .action(async ({ parsedInput: { rsvpLimit }, ctx: { user, userId } }) => { - await kv.set("config:registration:maxRSVPs", rsvpLimit); + await redisSet("config:registration:maxRSVPs", rsvpLimit); revalidatePath("/admin/toggles/registration"); return { success: true, statusSet: rsvpLimit }; }); diff --git a/apps/web/src/actions/user-profile-mod.ts b/apps/web/src/actions/user-profile-mod.ts index ee604ca7..8297d6b4 100644 --- a/apps/web/src/actions/user-profile-mod.ts +++ b/apps/web/src/actions/user-profile-mod.ts @@ -5,15 +5,22 @@ import { z } from "zod"; import { db } from "db"; import { userCommonData, userHackerData } from "db/schema"; import { eq } from "db/drizzle"; -import { put } from "@vercel/blob"; +import { del } from "@vercel/blob"; import { decodeBase64AsFile } from "@/lib/utils/shared/files"; -import { returnValidationErrors } from "next-safe-action"; import { revalidatePath } from "next/cache"; -import { getUser, getUserByTag } from "db/functions"; -import { RegistrationSettingsFormValidator } from "@/validators/shared/RegistrationSettingsForm"; +import { UNIQUE_KEY_CONSTRAINT_VIOLATION_CODE } from "@/lib/constants"; +import c from "config"; +import { DatabaseError } from "db/types"; +import { + registrationSettingsFormValidator, + modifyAccountSettingsSchema, + profileSettingsSchema, +} from "@/validators/settings"; +import { clerkClient, type User as ClerkUser } from "@clerk/nextjs/server"; +import { PAYLOAD_TOO_LARGE_CODE } from "@/lib/constants"; export const modifyRegistrationData = authenticatedAction - .schema(RegistrationSettingsFormValidator) + .schema(registrationSettingsFormValidator) .action( async ({ parsedInput: { @@ -37,12 +44,12 @@ export const modifyRegistrationData = authenticatedAction personalWebsite, phoneNumber, countryOfResidence, + uploadedFile, }, ctx: { userId }, }) => { - const user = await getUser(userId); - if (!user) throw new Error("User not found"); await Promise.all([ + // attempts to update both tables with Promise.all db .update(userCommonData) .set({ @@ -56,7 +63,7 @@ export const modifyRegistrationData = authenticatedAction phoneNumber, countryOfResidence, }) - .where(eq(userCommonData.clerkID, user.clerkID)), + .where(eq(userCommonData.clerkID, userId)), db .update(userHackerData) .set({ @@ -71,9 +78,18 @@ export const modifyRegistrationData = authenticatedAction GitHub: github, LinkedIn: linkedin, PersonalWebsite: personalWebsite, + resume: uploadedFile, }) - .where(eq(userHackerData.clerkID, user.clerkID)), - ]); + .where(eq(userHackerData.clerkID, userId)), + ]).catch(async (err) => { + console.log( + `Error occured at modify registration data: ${err}`, + ); + // If there's an error + return { + success: false, + }; + }); return { success: true, newAge: age, @@ -96,99 +112,72 @@ export const modifyRegistrationData = authenticatedAction newPersonalWebsite: personalWebsite, newPhoneNumber: phoneNumber, newCountryOfResidence: countryOfResidence, + newUploadedFile: uploadedFile, }; }, ); -export const modifyResume = authenticatedAction +export const deleteResume = authenticatedAction .schema( z.object({ - resume: z.string(), + oldFileLink: z.string(), }), ) - .action(async ({ parsedInput: { resume }, ctx: { userId } }) => { + .action(async ({ parsedInput: { oldFileLink } }) => { + if (oldFileLink === c.noResumeProvidedURL) return null; + await del(oldFileLink); + }); + +export const modifyProfileData = authenticatedAction + .schema(profileSettingsSchema) + .action(async ({ parsedInput, ctx: { userId } }) => { await db - .update(userHackerData) - .set({ resume }) - .where(eq(userHackerData.clerkID, userId)); + .update(userCommonData) + .set({ + ...parsedInput, + skills: parsedInput.skills.map((v) => v.toLowerCase()), + }) + .where(eq(userCommonData.clerkID, userId)); return { success: true, - newResume: resume, }; }); -export const modifyProfileData = authenticatedAction - .schema( - z.object({ - pronouns: z.string(), - bio: z.string(), - skills: z.string().array(), - discord: z.string(), - }), - ) - .action( - async ({ - parsedInput: { bio, discord, pronouns, skills }, - ctx: { userId }, - }) => { - const user = await getUser(userId); - if (!user) { - throw new Error("User not found"); - } - await db - .update(userCommonData) - .set({ pronouns, bio, skills, discord }) - .where(eq(userCommonData.clerkID, user.clerkID)); - return { - success: true, - newPronouns: pronouns, - newBio: bio, - newSkills: skills, - newDiscord: discord, - }; - }, - ); - -// TODO: Fix after registration enhancements to allow for failure on conflict and return appropriate error message export const modifyAccountSettings = authenticatedAction - .schema( - z.object({ - firstName: z.string().min(1).max(50), - lastName: z.string().min(1).max(50), - hackerTag: z.string().min(1).max(50), - hasSearchableProfile: z.boolean(), - }), - ) + .schema(modifyAccountSettingsSchema) .action( async ({ parsedInput: { firstName, lastName, hackerTag, - hasSearchableProfile, + isSearchable: hasSearchableProfile, }, ctx: { userId }, }) => { - const user = await getUser(userId); - if (!user) throw new Error("User not found"); - let oldHackerTag = user.hackerTag; // change when hackertag is not PK on profileData table - if (oldHackerTag != hackerTag) - if (await getUserByTag(hackerTag)) - //if hackertag changed - // copied from /api/registration/create + try { + await db + .update(userCommonData) + .set({ + firstName, + lastName, + hackerTag, + isSearchable: hasSearchableProfile, + }) + .where(eq(userCommonData.clerkID, userId)); + } catch (err) { + console.log("modifyAccountSettings error is", err); + if ( + err instanceof DatabaseError && + err.code === UNIQUE_KEY_CONSTRAINT_VIOLATION_CODE + ) { return { success: false, message: "hackertag_not_unique", }; - await db - .update(userCommonData) - .set({ - firstName, - lastName, - hackerTag, - isSearchable: hasSearchableProfile, - }) - .where(eq(userCommonData.clerkID, userId)); + } + throw err; + } return { success: true, newFirstName: firstName, @@ -199,23 +188,43 @@ export const modifyAccountSettings = authenticatedAction }, ); +// come back and fix this tmr export const updateProfileImage = authenticatedAction .schema(z.object({ fileBase64: z.string(), fileName: z.string() })) .action( async ({ parsedInput: { fileBase64, fileName }, ctx: { userId } }) => { - const image = await decodeBase64AsFile(fileBase64, fileName); - const user = await db.query.userCommonData.findFirst({ - where: eq(userCommonData.clerkID, userId), - }); - if (!user) throw new Error("User not found"); + const file = await decodeBase64AsFile(fileBase64, fileName); + let clerkUser: ClerkUser; + try { + clerkUser = await clerkClient.users.updateUserProfileImage( + userId, + { + file, + }, + ); + } catch (err) { + console.log(`Error updating Clerk profile image: ${err}`); + if ( + typeof err === "object" && + err != null && + "status" in err && + err.status === PAYLOAD_TOO_LARGE_CODE + ) { + return { + success: false, + message: "file_too_large", + }; + } + console.log( + `Unknown Error updating Clerk profile image: ${err}`, + ); + throw err; + } - const blobUpload = await put(image.name, image, { - access: "public", - }); await db .update(userCommonData) - .set({ profilePhoto: blobUpload.url }) - .where(eq(userCommonData.clerkID, user.clerkID)); + .set({ profilePhoto: clerkUser.imageUrl }) + .where(eq(userCommonData.clerkID, userId)); revalidatePath("/settings#profile"); return { success: true }; }, diff --git a/apps/web/src/app/admin/toggles/landing/page.tsx b/apps/web/src/app/admin/toggles/landing/page.tsx index 3cc22a64..7d47f644 100644 --- a/apps/web/src/app/admin/toggles/landing/page.tsx +++ b/apps/web/src/app/admin/toggles/landing/page.tsx @@ -1,6 +1,6 @@ import { NavItemsManager, - NavItemDialog, + AddNavItemDialog, } from "@/components/admin/toggles/NavItemsManager"; import { getAllNavItems } from "@/lib/utils/server/redis"; @@ -13,7 +13,7 @@ export default async function Page() { Navbar Items
- +
-
+
+

@@ -48,7 +23,6 @@ export default async function Page({

-
- {/* TODO: Would very much like to not have "as any" here in the future */} -
+
{userData && userData.length > 0 ? ( <> - + ) : (

No Results :(

)} - {/* */}
-
); } diff --git a/apps/web/src/app/dash/pass/page.tsx b/apps/web/src/app/dash/pass/page.tsx index dfbfefd7..41c6a4c1 100644 --- a/apps/web/src/app/dash/pass/page.tsx +++ b/apps/web/src/app/dash/pass/page.tsx @@ -78,6 +78,7 @@ function EventPass({ qrPayload, user, clerk, guild }: EventPassProps) { src={c.eventPassBgImage} alt={""} fill + priority className="no-select -translate-y-[15%] scale-[0.8] object-contain" />
diff --git a/apps/web/src/app/register/page.tsx b/apps/web/src/app/register/page.tsx index db808b98..26a24250 100644 --- a/apps/web/src/app/register/page.tsx +++ b/apps/web/src/app/register/page.tsx @@ -4,7 +4,7 @@ import { auth, currentUser } from "@clerk/nextjs"; import { redirect } from "next/navigation"; import Navbar from "@/components/shared/Navbar"; import Link from "next/link"; -import { kv } from "@vercel/kv"; +import { redisMGet } from "@/lib/utils/server/redis"; import { parseRedisBoolean } from "@/lib/utils/server/redis"; import { Button } from "@/components/shadcn/ui/button"; import { getUser } from "db/functions"; @@ -22,7 +22,7 @@ export default async function Page() { const [defaultRegistrationEnabled, defaultSecretRegistrationEnabled]: ( | string | null - )[] = await kv.mget( + )[] = await redisMGet( "config:registration:registrationEnabled", "config:registration:secretRegistrationEnabled", ); diff --git a/apps/web/src/app/rsvp/page.tsx b/apps/web/src/app/rsvp/page.tsx index 4de7c7bf..841f315c 100644 --- a/apps/web/src/app/rsvp/page.tsx +++ b/apps/web/src/app/rsvp/page.tsx @@ -7,8 +7,11 @@ import { eq } from "db/drizzle"; import { userCommonData } from "db/schema"; import ClientToast from "@/components/shared/ClientToast"; import { SignedOut, RedirectToSignIn } from "@clerk/nextjs"; -import { kv } from "@vercel/kv"; -import { parseRedisBoolean, parseRedisNumber } from "@/lib/utils/server/redis"; +import { + parseRedisBoolean, + parseRedisNumber, + redisGet, +} from "@/lib/utils/server/redis"; import Link from "next/link"; import { Button } from "@/components/shadcn/ui/button"; import { getUser } from "db/functions"; @@ -41,7 +44,7 @@ export default async function RsvpPage({ } const rsvpEnabled = parseRedisBoolean( - (await kv.get("config:registration:allowRSVPs")) as + (await redisGet("config:registration:allowRSVPs")) as | string | boolean | null @@ -53,7 +56,7 @@ export default async function RsvpPage({ if (rsvpEnabled === true) { const rsvpLimit = parseRedisNumber( - await kv.get("config:registration:maxRSVPs"), + await redisGet("config:registration:maxRSVPs"), c.rsvpDefaultLimit, ); diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 685c8251..f38af226 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -10,13 +10,13 @@ export default async function Page() { if (!userId) return redirect("/sign-in"); const user = await getUser(userId); if (!user) return redirect("/sign-in"); - + const { email, ...userData } = user; return (
- +
- +
diff --git a/apps/web/src/app/sign-up/[[...sign-up]]/page.tsx b/apps/web/src/app/sign-up/[[...sign-up]]/page.tsx index 057a6407..49244c2e 100644 --- a/apps/web/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/apps/web/src/app/sign-up/[[...sign-up]]/page.tsx @@ -1,5 +1,5 @@ import { SignUp } from "@clerk/nextjs"; -import { kv } from "@vercel/kv"; +import { redisMGet } from "@/lib/utils/server/redis"; import { parseRedisBoolean } from "@/lib/utils/server/redis"; import c from "config"; import { Button } from "@/components/shadcn/ui/button"; @@ -9,7 +9,7 @@ export default async function Page() { const [defaultRegistrationEnabled, defaultSecretRegistrationEnabled]: ( | string | null - )[] = await kv.mget( + )[] = await redisMGet( "config:registration:registrationEnabled", "config:registration:secretRegistrationEnabled", ); diff --git a/apps/web/src/components/admin/toggles/NavItemsManager.tsx b/apps/web/src/components/admin/toggles/NavItemsManager.tsx index a24784c3..237590d6 100644 --- a/apps/web/src/components/admin/toggles/NavItemsManager.tsx +++ b/apps/web/src/components/admin/toggles/NavItemsManager.tsx @@ -22,13 +22,14 @@ import { import { Button } from "@/components/shadcn/ui/button"; import { Input } from "@/components/shadcn/ui/input"; import { Label } from "@/components/shadcn/ui/label"; -import { Plus } from "lucide-react"; +import { Plus, Loader2 } from "lucide-react"; import { useState } from "react"; import { useAction, useOptimisticAction } from "next-safe-action/hooks"; import { setItem, removeItem, toggleItem, + editItem, } from "@/actions/admin/modify-nav-item"; import { toast } from "sonner"; import Link from "next/link"; @@ -41,8 +42,13 @@ interface NavItemsManagerProps { export function NavItemsManager({ navItems }: NavItemsManagerProps) { const { execute, result, status } = useAction(removeItem, { onSuccess: () => { + toast.dismiss(); toast.success("NavItem deleted successfully!"); }, + onError: () => { + toast.dismiss(); + toast.error("Error deleting NavItem"); + }, }); return ( @@ -79,12 +85,16 @@ export function NavItemsManager({ navItems }: NavItemsManagerProps) { name={item.name} /> - - + + + + + + ); +} + +interface EditNavItemDialogProps { + existingName: string; + existingUrl: string; + existingEnabled: boolean; +} + +function EditNavItemDialog({ + existingName, + existingUrl, + existingEnabled, +}: EditNavItemDialogProps) { + const [name, setName] = useState(existingName); + const [url, setUrl] = useState(existingUrl); + const [open, setOpen] = useState(false); + + const { execute, status: editStatus } = useAction(editItem, { + onSuccess: () => { + console.log("Success"); + setOpen(false); + toast.success("NavItem edited successfully!"); + }, + onError: () => { + toast.error("Error editing NavItem"); + }, + }); + const isLoading = editStatus === "executing"; + + return ( + + + + + + + Edit Item + + Edit an existing item shown in the non-dashboard navbar + + +
+
+ + setName(e.target.value)} + id="name" + placeholder="A Cool Hyperlink" + className="col-span-3" + value={name} + /> +
+
+ + setUrl(e.target.value)} + id="name" + placeholder="https://example.com/" + className="col-span-3" + value={url} + /> +
+
+ +
diff --git a/apps/web/src/components/admin/users/DefaultPagination.tsx b/apps/web/src/components/admin/users/DefaultPagination.tsx deleted file mode 100644 index a0d5771c..00000000 --- a/apps/web/src/components/admin/users/DefaultPagination.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/shadcn/ui/pagination"; -import { useEffect, useRef, useState } from "react"; -import { usePathname, useSearchParams } from "next/navigation"; -import { createPath } from "@/lib/utils/shared/pageParams"; - -export function DefaultPagination({ maxPages }: { maxPages: number }) { - // FIXME: Come back and change after done testing - - const path = usePathname(); - const params = useSearchParams(); - - const page = params.get("page") ?? "1"; - - const [currPage, setCurrPage] = useState(+page); - const pageRef = useRef(1); - - function incPage() { - pageRef.current = Math.min(maxPages, pageRef.current + 1); - setCurrPage(Math.min(maxPages, currPage + 1)); - } - - function decPage() { - pageRef.current = Math.max(1, pageRef.current - 1); - setCurrPage(Math.max(1, currPage - 1)); - } - - function createPaginationPath(reqPage: string) { - const url = `${path}?${reqPage}&user=${ - params.get("user") ?? "" - }&checkedBoxes=${params.get("checkedBoxes") ?? ""}`; - console.log("Pagination", url); - return url; - } - - return ( - - - - { - decPage(); - }} - /> - - {currPage} - - { - incPage(); - }} - /> - - - - ); -} diff --git a/apps/web/src/components/admin/users/SearchBar.tsx b/apps/web/src/components/admin/users/SearchBar.tsx deleted file mode 100644 index 2af198de..00000000 --- a/apps/web/src/components/admin/users/SearchBar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { Input } from "@/components/shadcn/ui/input"; -import { useRouter, useSearchParams, usePathname } from "next/navigation"; -import { useRef, useState } from "react"; -import { useDebouncedCallback } from "use-debounce"; - -import { X } from "lucide-react"; -export default function SearchBar() { - const searchParams = useSearchParams(); - const { replace } = useRouter(); - const pathname = usePathname(); - - // We use a debouncing strategy to prevent the search from querying every single keystroke and instead will run a time after the user completes typing - const handleSearch = useDebouncedCallback((term) => { - // @ts-ignore Works perfectly fine and is apprporiate accoriding to the docs. Might be a version issue? - const params = new URLSearchParams(searchParams); - if (term) { - params.set("user", term); - } else { - params.delete("user"); - } - replace(`${pathname}?${params.toString()}`); - }, 100); - - return ( -
- {/* Needs to clear text */} - handleSearch(e.target.value)} - /> - -
- ); -} diff --git a/apps/web/src/components/admin/users/UserColumns.tsx b/apps/web/src/components/admin/users/UserColumns.tsx index a65e1ec8..63ecc115 100644 --- a/apps/web/src/components/admin/users/UserColumns.tsx +++ b/apps/web/src/components/admin/users/UserColumns.tsx @@ -6,46 +6,139 @@ import { createSelectSchema } from "drizzle-zod"; import { userCommonData } from "db/schema"; import Link from "next/link"; import { Button } from "@/components/shadcn/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../../shadcn/ui/dropdown-menu"; +import { Input } from "@/components/shadcn/ui/input"; +import { MoreHorizontal, ArrowUpDown, User } from "lucide-react"; +import type { Column, Row } from "@tanstack/react-table"; +import { dataTableFuzzyFilter } from "@/lib/utils/client/shared"; +import { Badge } from "@/components/shadcn/ui/badge"; const userValidator = createSelectSchema(userCommonData); +// default fuzzy search and add filters by each column if possible export type userValidatorType = Pick< z.infer, | "clerkID" | "signupTime" | "firstName" | "lastName" - | "hackerTag" | "email" | "role" + | "isRSVPed" + | "hackerTag" + | "checkinTimestamp" >; +type UserColumnType = Column; + export const columns: ColumnDef[] = [ { - accessorKey: "firstName", - header: "Name", - cell: ({ row }) => `${row.original.firstName} ${row.original.lastName}`, + accessorFn: (row) => `${row.firstName} ${row.lastName}`, + id: "name", + header: ({ column }) => ( + + ), + cell: (info) => info.getValue(), + // filterFn: (row, _columnId, filterValue) => { + // return row.original.firstName.toLocaleLowerCase().includes(filterValue.toLocaleLowerCase()) || row.original.lastName.toLocaleLowerCase().includes(filterValue.toLocaleLowerCase()); + // }, + filterFn: dataTableFuzzyFilter, }, { accessorKey: "email", - header: "Email", + header: ({ column }) => ( + + ), + filterFn: "includesString", + cell: (info) => info.getValue(), }, { accessorKey: "hackerTag", - header: "Hacker Tag", + header: ({ column }) => ( + + ), cell: ({ row }) => `@${row.original.hackerTag}`, + filterFn: dataTableFuzzyFilter, }, { - accessorKey: "clerkID", - header: "Account ID", + accessorKey: "role", + header: ({ column }) => ( + + ), + filterFn: "includesString", }, { - accessorKey: "role", - header: "Role", + accessorKey: "isRSVPed", + header: ({ column }) => ( +
+ RSVP Status + +
+ ), + // row.original.isRSVPed ? + cell: ({ row }) => ( + +
+ + RSVP: {row.original.isRSVPed ? "Yes" : "No"} + + + ), }, + { + accessorKey: "checkinTimestamp", + header: ({ column }) => ( +
+ Checkin Time + +
+ ), + cell: ({ row }) => ( + + {row.original.checkinTimestamp + ? new Date( + row.original.checkinTimestamp, + ).toLocaleDateString() + + " " + + new Date( + row.original.checkinTimestamp, + ).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }) + : "Not Checked In"} + + ), + }, + // { + // accessorKey: "role", + // header: ({ column }) => ( + // + // ), + // filterFn: "includesString", + // }, { accessorKey: "signupTime", - header: "Signup Date", + header: ({ column }) => ( +
+ Signup Time + +
+ ), cell: ({ row }) => ( {new Date(row.original.signupTime).toLocaleDateString() + " "} @@ -57,12 +150,85 @@ export const columns: ColumnDef[] = [ ), }, { - accessorKey: "clerkID2", - header: "View", - cell: ({ row }) => ( - - - - ), + id: "actions", + enableHiding: false, + cell: ({ row }) => { + return ; + }, }, ]; + +function UserDropDownActions({ row }: { row: Row }) { + const user = row.original; + return ( + + + + + + + View User + + navigator.clipboard.writeText(user.clerkID)} + className="cursor-pointer" + > + Copy Clerk ID + + + + Email User + + + + + ); +} + +function UserTableHeader({ + name, + column, + hasFilter, +}: { + name: string; + column: UserColumnType; + hasFilter: boolean; +}) { + return ( +
+
+ {name} + {hasFilter && } +
+ column.setFilterValue(e.target.value)} + placeholder="search..." + /> +
+ ); +} + +function SortColumnButton({ + name, + column, +}: { + name: string; + column: UserColumnType; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/admin/users/UserDataTable.tsx b/apps/web/src/components/admin/users/UserDataTable.tsx index 5f4191b9..a9a0aa55 100644 --- a/apps/web/src/components/admin/users/UserDataTable.tsx +++ b/apps/web/src/components/admin/users/UserDataTable.tsx @@ -5,8 +5,12 @@ import { flexRender, getCoreRowModel, useReactTable, + SortingState, + getSortedRowModel, + getPaginationRowModel, + ColumnFiltersState, + getFilteredRowModel, } from "@tanstack/react-table"; - import { Table, TableBody, @@ -15,10 +19,10 @@ import { TableHeader, TableRow, } from "@/components/shadcn/ui/table"; - -import { useCallback } from "react"; -import { useRouter } from "next/navigation"; - +import { Input } from "@/components/shadcn/ui/input"; +import { Button } from "@/components/shadcn/ui/button"; +import { useEffect, useState } from "react"; +import { dataTableFuzzyFilter } from "@/lib/utils/client/shared"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; @@ -28,68 +32,124 @@ export function DataTable({ columns, data, }: DataTableProps) { - const formatTrProps = (state = {}) => { - console.log("qua"); - return { onClick: () => console.log("state", state) }; - }; + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); const table = useReactTable({ data, columns, + filterFns: { + fuzzy: dataTableFuzzyFilter, + }, + state: { + sorting, + columnFilters, + globalFilter, + }, + onColumnFiltersChange: setColumnFilters, + globalFilterFn: dataTableFuzzyFilter, getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), }); + useEffect(() => { + console.log("column filters", columnFilters); + }, [columnFilters]); + return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( +
+
+
+ { + // we want to set our global filter + setGlobalFilter(event.target.value); + }} + className="max-w-sm" + /> +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ); + })} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
+
+ + +
); } diff --git a/apps/web/src/components/dash/shared/ProfileButton.tsx b/apps/web/src/components/dash/shared/ProfileButton.tsx index 5f2b8d13..f13cfcdd 100644 --- a/apps/web/src/components/dash/shared/ProfileButton.tsx +++ b/apps/web/src/components/dash/shared/ProfileButton.tsx @@ -17,7 +17,7 @@ import { auth, SignOutButton } from "@clerk/nextjs"; import Link from "next/link"; import { DropdownSwitcher } from "@/components/shared/ThemeSwitcher"; import { getUser } from "db/functions"; -import { clientLogOut } from "@/lib/utils/client/shared"; +import { clientLogOut } from "@/lib/utils/server/user"; export default async function ProfileButton() { const clerkUser = auth(); diff --git a/apps/web/src/components/settings/AccountSettings.tsx b/apps/web/src/components/settings/AccountSettings.tsx index 9e693dad..ace89637 100644 --- a/apps/web/src/components/settings/AccountSettings.tsx +++ b/apps/web/src/components/settings/AccountSettings.tsx @@ -4,31 +4,40 @@ import { Input } from "@/components/shadcn/ui/input"; import { Button } from "@/components/shadcn/ui/button"; import { Label } from "@/components/shadcn/ui/label"; import { toast } from "sonner"; -import { useState } from "react"; import { useAction } from "next-safe-action/hooks"; import { modifyAccountSettings } from "@/actions/user-profile-mod"; import { Checkbox } from "@/components/shadcn/ui/checkbox"; -import c from "config"; import { Loader2 } from "lucide-react"; import { isProfane } from "no-profanity"; +import { modifyAccountSettingsSchema } from "@/validators/settings"; +import z from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "../shadcn/ui/form"; -interface UserProps { - firstName: string; - lastName: string; - email: string; - hackerTag: string; - isSearchable: boolean; -} +type UserProps = z.infer; -export default function AccountSettings({ user }: { user: UserProps }) { - const [newFirstName, setNewFirstName] = useState(user.firstName); - const [newLastName, setNewLastName] = useState(user.lastName); - //const [newEmail, setNewEmail] = useState(user.email); - const [newHackerTag, setNewHackerTag] = useState(user.hackerTag); - const [newIsProfileSearchable, setNewIsProfileSearchable] = useState( - user.isSearchable, - ); - const [hackerTagTakenAlert, setHackerTagTakenAlert] = useState(false); +export default function AccountSettings({ + user, + email, +}: { + user: UserProps; + email: string; +}) { + const form = useForm({ + resolver: zodResolver(modifyAccountSettingsSchema), + defaultValues: { + ...user, + }, + }); const { execute: runModifyAccountSettings, status: loadingState } = useAction(modifyAccountSettings, { @@ -36,10 +45,22 @@ export default function AccountSettings({ user }: { user: UserProps }) { toast.dismiss(); if (!data?.success) { if (data?.message == "hackertag_not_unique") { - toast.error("Hackertag already exists"); - setHackerTagTakenAlert(true); + toast.error( + `Hackertag '${form.getValues("hackerTag")}' already exists`, + ); + form.setError("hackerTag", { + message: "Hackertag already exists", + }); + form.setValue("hackerTag", user.hackerTag); } - } else toast.success("Account updated successfully!"); + } else { + toast.success("Account updated successfully!", { + duration: 1500, + }); + form.reset({ + ...form.getValues(), + }); + } }, onError: () => { toast.dismiss(); @@ -49,122 +70,126 @@ export default function AccountSettings({ user }: { user: UserProps }) { }, }); + function handleSubmit(data: UserProps) { + toast.dismiss(); + console.log("form is dirty", form.formState.dirtyFields); + console.log(form.formState.isDirty); + if (!form.formState.isDirty) { + toast.error("Please change something before updating"); + return; + } + runModifyAccountSettings(data); + } + return (
-
-

- Personal Information -

-
-
- - setNewFirstName(e.target.value)} - /> - {!newFirstName ? ( -
- This field can't be empty! -
- ) : null} -
-
- - setNewLastName(e.target.value)} - /> - {!newLastName ? ( -
- This field can't be empty! -
- ) : null} -
-
-

- Public Information -

-
-
- -
-
- @ -
- { - setNewHackerTag(e.target.value); - setHackerTagTakenAlert(false); - }} +
+ +
+

+ Personal Information +

+
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} /> + + Email + + + This field cannot be changed. + +
- {hackerTagTakenAlert ? ( -
- HackerTag is already taken! -
- ) : ( - "" - )} - {!newHackerTag ? ( -
- This field can't be empty! -
- ) : null} -
-
- - setNewIsProfileSearchable( - !newIsProfileSearchable, - ) - } - /> - +

+ Public Information +

+
+ ( + + Hacker Tag + +
+
+ @ +
+ +
+
+ +
+ )} + /> + ( + + + + +
+ + Make my profile searchable by + other hackers + +
+
+ )} + /> +
+
-
- -
+ +
); } diff --git a/apps/web/src/components/settings/ProfilePhotoSettings.tsx b/apps/web/src/components/settings/ProfilePhotoSettings.tsx new file mode 100644 index 00000000..e15133b1 --- /dev/null +++ b/apps/web/src/components/settings/ProfilePhotoSettings.tsx @@ -0,0 +1,115 @@ +"use client"; +import { Avatar, AvatarImage } from "../shadcn/ui/avatar"; +import { encodeFileAsBase64 } from "@/lib/utils/shared/files"; +import { updateProfileImage } from "@/actions/user-profile-mod"; +import { useAction } from "next-safe-action/hooks"; +import { toast } from "sonner"; +import { useRef, useState } from "react"; +import { Input } from "../shadcn/ui/input"; +import { Button } from "../shadcn/ui/button"; +import { Loader2 } from "lucide-react"; + +export default function ProfilePhotoSettings({ + profilePhoto, +}: { + profilePhoto: string; +}) { + const [newProfileImage, setNewProfileImage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + // this input will either be null or a reference to the input element + const profileInputRef = useRef(null); + + const { execute: runUpdateProfileImage } = useAction(updateProfileImage, { + onSuccess: (res) => { + setIsLoading(false); + setNewProfileImage(null); + toast.dismiss(); + if (profileInputRef.current) { + profileInputRef.current.value = ""; + } + console.log(`res data message: ${res.data?.message}`); + if (!res.data?.success) { + toast.error( + "An error occurred while updating your profile photo!", + ); + return; + } + if (res.data?.message === "file_too_large") { + toast.error("Please upload a file smaller than 10MB"); + return; + } + toast.success("Profile Photo updated successfully!"); + }, + onError: () => { + setIsLoading(false); + toast.dismiss(); + if (profileInputRef.current) { + profileInputRef.current.value = ""; + } + toast.error("An error occurred while updating your profile photo!"); + }, + }); + + const handleFileChange = (event: React.ChangeEvent) => { + let file = event.target.files?.[0] || null; + if ((file?.size || -1) > 10000000) { + file = null; + profileInputRef.current!.value = ""; + toast.error("Please upload a file smaller than 10MB"); + } + setNewProfileImage(file); + }; + return ( +
+

Profile Photo

+
+
+ + + + +

+ Note: Only pictures less 10MB will be accepted. +

+
+ +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProfileSettings.tsx b/apps/web/src/components/settings/ProfileSettings.tsx index 8f455aaa..d4b0dd42 100644 --- a/apps/web/src/components/settings/ProfileSettings.tsx +++ b/apps/web/src/components/settings/ProfileSettings.tsx @@ -4,18 +4,25 @@ import { Input } from "@/components/shadcn/ui/input"; import { Button } from "@/components/shadcn/ui/button"; import { Label } from "@/components/shadcn/ui/label"; import { Textarea } from "@/components/shadcn/ui/textarea"; -import { - modifyProfileData, - updateProfileImage, -} from "@/actions/user-profile-mod"; -import { useUser } from "@clerk/nextjs"; +import ProfilePhotoSettings from "./ProfilePhotoSettings"; +import { modifyProfileData } from "@/actions/user-profile-mod"; import { useAction } from "next-safe-action/hooks"; import { toast } from "sonner"; -import { useState } from "react"; -import { encodeFileAsBase64 } from "@/lib/utils/shared/files"; +import { useEffect, useState } from "react"; import { Tag, TagInput } from "@/components/shadcn/ui/tag/tag-input"; import { Loader2 } from "lucide-react"; -import { Avatar, AvatarImage } from "@/components/shadcn/ui/avatar"; +import { useForm } from "react-hook-form"; +import { profileSettingsSchema } from "@/validators/settings"; +import z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../shadcn/ui/form"; interface ProfileData { pronouns: string; @@ -24,202 +31,153 @@ interface ProfileData { discord: string | null; profilePhoto: string; } - interface ProfileSettingsProps { profile: ProfileData; } -export default function ProfileSettings({ profile }: ProfileSettingsProps) { - const [newPronouns, setNewPronouns] = useState(profile.pronouns); - const [newBio, setNewBio] = useState(profile.bio); - const [newProfileImage, setNewProfileImage] = useState(null); - let curSkills: Tag[] = []; - // for (let i = 0; i < profile.skills.length; i++) { - // let t: Tag = { - // id: profile.skills[i], - // text: profile.skills[i], - // }; - // curSkills.push(t); - // } - profile.skills.map((skill) => { - curSkills.push({ - id: skill, - text: skill, - }); - }); - const [newSkills, setNewSkills] = useState(curSkills); - const [newDiscord, setNewDiscord] = useState(profile.discord || ""); - - const [isProfilePictureLoading, setIsProfilePictureLoading] = - useState(false); - const [isProfileSettingsLoading, setIsProfileSettingsLoading] = - useState(false); - - const { user } = useUser(); +export default function ProfileSettings({ + profile: profileData, +}: ProfileSettingsProps) { + const { profilePhoto, ...profileSettingsData } = profileData; + const skillTags: Tag[] = profileSettingsData.skills.map((skill) => ({ + id: skill, + text: skill, + })); + const [newSkills, setNewSkills] = useState(skillTags); - const { execute: runModifyProfileData } = useAction(modifyProfileData, { - onSuccess: () => { - setIsProfileSettingsLoading(false); - toast.dismiss(); - toast.success("Profile updated successfully!"); - }, - onError: () => { - setIsProfileSettingsLoading(false); - toast.dismiss(); - toast.error("An error occurred while updating your profile!"); + const form = useForm>({ + resolver: zodResolver(profileSettingsSchema), + defaultValues: { + ...profileSettingsData, + discord: profileSettingsData.discord || "", }, }); - const { execute: runUpdateProfileImage } = useAction(updateProfileImage, { - onSuccess: async () => { - setIsProfilePictureLoading(false); - toast.dismiss(); - await user?.setProfileImage({ file: newProfileImage }); - toast.success("Profile Photo updated successfully!"); - }, - onError: (err) => { - setIsProfilePictureLoading(false); - toast.dismiss(); - toast.error("An error occurred while updating your profile photo!"); - console.error(err); + useEffect(() => { + form.setValue("skills", [...newSkills.map((tag) => tag.text)], { + shouldDirty: true, + }); + }, [newSkills]); + + const { execute: runModifyProfileData, status: actionStatus } = useAction( + modifyProfileData, + { + onSuccess: () => { + toast.dismiss(); + toast.success("Profile Data updated successfully!"); + form.reset({ + ...form.getValues(), + }); + }, + onError: () => { + toast.dismiss(); + toast.error("An error occurred while updating your profile!"); + }, }, - }); + ); + + function handleUpdate(data: z.infer) { + if (!form.formState.isDirty) { + toast.error("Please change something before updating"); + return; + } + runModifyProfileData({ + ...data, + skills: data.skills.map((skill) => skill), + }); + } - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files ? event.target.files[0] : null; - setNewProfileImage(file); - }; + const isProfileSettingsLoading = actionStatus === "executing"; return (
-
-

Profile Photo

-
-
- - - - +
+ +

+ Profile Data +

+
+ ( + + Pronouns + + + + + + )} /> -
- -
-
-
-

Profile Data

-
- - setNewPronouns(e.target.value)} - /> -
-
-
- -