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 (
+
);
}
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);
- }}
+
-
-
+
+
);
}
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
-
-
- setNewPronouns(e.target.value)}
- />
-
-
-
-
-
-
-
- {
- setNewSkills(newTags);
- }}
- />
-
-
-
-
setNewDiscord(e.target.value)}
+
+
+ {
+ setNewSkills(newTags);
+ }}
+ />
+
+
(
+
+ Discord Username
+
+
+
+
+
+ )}
/>
+
-
-
-
+
+
);
}
diff --git a/apps/web/src/components/settings/RegistrationForm/RegisterFormSettings.tsx b/apps/web/src/components/settings/RegistrationForm/RegisterFormSettings.tsx
index 3247db36..20ca0dde 100644
--- a/apps/web/src/components/settings/RegistrationForm/RegisterFormSettings.tsx
+++ b/apps/web/src/components/settings/RegistrationForm/RegisterFormSettings.tsx
@@ -23,7 +23,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FormGroupWrapper from "@/components/registration/FormGroupWrapper";
import { Checkbox } from "@/components/shadcn/ui/checkbox";
-import c from "config";
+import c, { bucketResumeBaseUploadUrl } from "config";
import {
Command,
CommandEmpty,
@@ -39,21 +39,20 @@ import {
} from "@/components/shadcn/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils/client/cn";
-import { useEffect, useCallback, useState } from "react";
+import { useEffect, useCallback, useState, useRef } from "react";
import { Textarea } from "@/components/shadcn/ui/textarea";
import { FileRejection, useDropzone } from "react-dropzone";
import { put } from "@vercel/blob";
import { useAction } from "next-safe-action/hooks";
import {
+ deleteResume,
modifyRegistrationData,
- modifyResume,
} from "@/actions/user-profile-mod";
import { toast } from "sonner";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { HackerData, User } from "db/types";
-import { RegistrationSettingsFormValidator } from "@/validators/shared/RegistrationSettingsForm";
-
+import { registrationSettingsFormValidator } from "@/validators/settings";
interface RegistrationFormSettingsProps {
user: User;
data: HackerData;
@@ -61,46 +60,68 @@ interface RegistrationFormSettingsProps {
export default function RegisterFormSettings({
user,
- data,
+ data: originalData,
}: RegistrationFormSettingsProps) {
- const form = useForm
>({
- resolver: zodResolver(RegistrationSettingsFormValidator),
+ const form = useForm>({
+ resolver: zodResolver(registrationSettingsFormValidator),
defaultValues: {
- hackathonsAttended: data.hackathonsAttended,
+ hackathonsAttended: originalData.hackathonsAttended,
dietaryRestrictions: user.dietRestrictions as any,
- isEmailable: data.isEmailable,
+ isEmailable: originalData.isEmailable,
accommodationNote: user.accommodationNote || "",
age: user.age,
ethnicity: user.ethnicity as any,
gender: user.gender as any,
- major: data.major,
- github: data.GitHub ?? "",
- heardAboutEvent: data.heardFrom as any,
- levelOfStudy: data.levelOfStudy as any,
- linkedin: data.LinkedIn ?? "",
- personalWebsite: data.PersonalWebsite ?? "",
+ major: originalData.major,
+ github: originalData.GitHub ?? "",
+ heardAboutEvent: originalData.heardFrom as any,
+ levelOfStudy: originalData.levelOfStudy as any,
+ linkedin: originalData.LinkedIn ?? "",
+ personalWebsite: originalData.PersonalWebsite ?? "",
race: user.race as any,
shirtSize: user.shirtSize as any,
- schoolID: data.schoolID,
- softwareBuildingExperience: data.softwareExperience as any,
- university: data.university,
+ schoolID: originalData.schoolID,
+ softwareBuildingExperience: originalData.softwareExperience as any,
+ university: originalData.university,
phoneNumber: user.phoneNumber,
countryOfResidence: user.countryOfResidence,
},
});
- const { isSubmitSuccessful, isSubmitted, errors } = form.formState;
- const hasErrors = !isSubmitSuccessful && isSubmitted;
+ const { isSubmitSuccessful, isSubmitted, isDirty } = form.formState;
+
const [uploadedFile, setUploadedFile] = useState(null);
- const resumeLink: string = data.resume ?? c.noResumeProvidedURL;
- // @ts-ignore
- let f = new File([data.resume], resumeLink.split("/").pop());
+ const [isOldFile, setIsOldFile] = useState(true);
+ const [hasDataChanged, setHasDataChanged] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const hasErrors = !isSubmitSuccessful && isSubmitted;
+ const oldResumeLink = useRef(originalData.resume);
+ let f = new File(
+ [originalData.resume],
+ oldResumeLink.current.split("/").pop()!,
+ );
+ let newResumeLink: string = originalData.resume;
+
+ // used to prevent infinite re-renders
useEffect(() => {
- if (resumeLink === c.noResumeProvidedURL) setUploadedFile(null);
+ if (oldResumeLink.current === c.noResumeProvidedURL)
+ setUploadedFile(null);
else setUploadedFile(f);
}, []);
- const [oldFile, setOldFile] = useState(true);
+ useEffect(() => {
+ console.log("isDirty: ", isDirty);
+ console.log("isOldFile: ", isOldFile);
+ console.log("uploadedFile: ", uploadedFile);
+ console.log("oldResumeLink: ", oldResumeLink.current);
+ setHasDataChanged(
+ isDirty ||
+ (uploadedFile != null && !isOldFile) ||
+ (oldResumeLink.current !== c.noResumeProvidedURL &&
+ uploadedFile == null),
+ );
+ }, [isDirty, uploadedFile, isOldFile, oldResumeLink.current]);
const universityValue = form.watch("university").toLowerCase();
const shortID = form.watch("schoolID").toLowerCase();
@@ -109,75 +130,76 @@ export default function RegisterFormSettings({
if (universityValue != c.localUniversityName.toLowerCase()) {
form.setValue("schoolID", "NOT_LOCAL_SCHOOL");
} else {
- if (shortID === "NOT_LOCAL_SCHOOL") {
- form.setValue("schoolID", "");
- } else {
- form.setValue("schoolID", data.schoolID);
- }
+ const ShortIDValue =
+ shortID === "NOT_LOCAL_SCHOOL" ? "" : originalData.schoolID;
+ form.setValue("schoolID", ShortIDValue);
}
}, [universityValue]);
async function onSubmit(
- data: z.infer,
+ data: z.infer,
) {
- let resume: string = c.noResumeProvidedURL;
-
- if (uploadedFile) {
- const newBlob = await put(uploadedFile.name, uploadedFile, {
- access: "public",
- handleBlobUploadUrl: "/api/upload/resume/register",
+ if (!hasDataChanged) {
+ toast.error("Please change something before updating");
+ return;
+ }
+ setIsLoading(true);
+ if (uploadedFile && !isOldFile) {
+ console.log("uploading file...");
+ const newBlob = await put(
+ bucketResumeBaseUploadUrl + "/" + uploadedFile.name,
+ uploadedFile,
+ {
+ access: "public",
+ handleBlobUploadUrl: "/api/upload/resume/register",
+ },
+ );
+ console.log("file uploaded");
+ newResumeLink = newBlob.url;
+ } else {
+ newResumeLink =
+ uploadedFile == null
+ ? c.noResumeProvidedURL
+ : originalData.resume;
+ }
+ oldResumeLink.current = newResumeLink;
+ const oldResume = originalData.resume;
+ if (hasDataChanged) {
+ console.log("running modify registration data...");
+ runModifyRegistrationData({
+ ...data,
+ uploadedFile: newResumeLink,
});
- resume = newBlob.url;
}
-
- const res = runModifyRegistrationData({
- age: data.age,
- gender: data.gender,
- race: data.race,
- ethnicity: data.ethnicity,
- isEmailable: data.isEmailable,
- university: data.university,
- major: data.major,
- levelOfStudy: data.levelOfStudy,
- schoolID: data.schoolID,
- hackathonsAttended: data.hackathonsAttended,
- softwareBuildingExperience: data.softwareBuildingExperience,
- heardAboutEvent: data.heardAboutEvent,
- shirtSize: data.shirtSize,
- dietaryRestrictions: data.dietaryRestrictions,
- accommodationNote: data.accommodationNote,
- github: data.github,
- linkedin: data.linkedin,
- personalWebsite: data.personalWebsite,
- phoneNumber: data.phoneNumber,
- countryOfResidence: data.countryOfResidence,
- });
- // Can be optimzed to run in the modify registratuib data action later.
- runModifyResume({ resume });
- console.log(res);
+ runDeleteResume({ oldFileLink: oldResume });
+ setIsLoading(false);
}
const { execute: runModifyRegistrationData, status: loadingState } =
useAction(modifyRegistrationData, {
- onSuccess: () => {
+ onSuccess: async () => {
toast.dismiss();
- toast.success("Data updated successfully!");
+ toast.success("Data updated successfully!", {
+ duration: 2000,
+ });
+ console.log("Success");
+ form.reset({
+ ...form.getValues(),
+ });
+ // setHasDataChanged(false);
+ setIsOldFile(true);
},
- onError: () => {
+ onError: async () => {
+ if (newResumeLink !== c.noResumeProvidedURL)
+ runDeleteResume({ oldFileLink: newResumeLink }); // If error, delete the blob write (of the attempted new resume)
+ setIsLoading(false);
toast.dismiss();
toast.error(
`An error occurred. Please contact ${c.issueEmail} for help.`,
);
},
});
-
- const { execute: runModifyResume } = useAction(modifyResume, {
- onSuccess: () => {},
- onError: () => {
- toast.dismiss();
- toast.error("An error occurred while uploading resume!");
- },
- });
+ const { execute: runDeleteResume } = useAction(deleteResume);
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
@@ -187,12 +209,11 @@ export default function RegisterFormSettings({
);
}
if (acceptedFiles.length > 0) {
- console.log(
- `Got accepted file! The length of the array is ${acceptedFiles.length}.`,
- );
- console.log(acceptedFiles[0]);
setUploadedFile(acceptedFiles[0]);
- setOldFile(false);
+ setIsOldFile(false);
+ setHasDataChanged(true);
+ } else {
+ setUploadedFile(null);
}
},
[],
@@ -1012,8 +1033,12 @@ export default function RegisterFormSettings({
{uploadedFile ? (
- oldFile ? (
-
+ isOldFile ? (
+
{uploadedFile.name}{" "}
(
{Math.round(
@@ -1035,7 +1060,7 @@ export default function RegisterFormSettings({
className="mt-4"
onClick={() => {
setUploadedFile(null);
- setOldFile(false);
+ setIsOldFile(false);
}}
>
Remove
@@ -1050,9 +1075,9 @@ export default function RegisterFormSettings({