From 9649e54f226fdc3f637e871c53529b6ba966280b Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Mon, 17 Nov 2025 11:59:59 -0500 Subject: [PATCH 1/9] feat: kick member button for teams --- apps/api/internal/api/api.go | 1 + apps/api/internal/api/handlers/teams.go | 48 +++++++++++++++++++ apps/api/internal/services/teams.go | 21 ++++++++ .../features/Team/components/MyTeamCard.tsx | 45 +++++++++++++---- .../src/features/Team/hooks/useTeamActions.ts | 16 ++++++- .../events/$eventId/dashboard/my-team.tsx | 18 ++++++- 6 files changed, 137 insertions(+), 12 deletions(-) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 9dcf67e3..be0af8e1 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -115,6 +115,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Get("/{teamId}", api.Handlers.Teams.GetTeam) r.Get("/{teamId}/pending-joins", api.Handlers.Teams.GetPendingRequestsForTeam) r.Delete("/{teamId}/members/me", api.Handlers.Teams.LeaveTeam) + r.Delete("/{teamId}/members/{userId}", api.Handlers.Teams.KickMemberFromTeam) r.Post("/join/{requestId}/accept", api.Handlers.Teams.AcceptTeamJoinRequest) r.Post("/join/{requestId}/reject", api.Handlers.Teams.RejectTeamJoinRequest) }) diff --git a/apps/api/internal/api/handlers/teams.go b/apps/api/internal/api/handlers/teams.go index 9c4741ff..5f82c4ed 100644 --- a/apps/api/internal/api/handlers/teams.go +++ b/apps/api/internal/api/handlers/teams.go @@ -490,6 +490,52 @@ func (h *TeamHandler) RejectTeamJoinRequest(w http.ResponseWriter, r *http.Reque res.Send(w, http.StatusNoContent, nil) } +// Kick a member from a team +// +// @Summary Kick a member from a team +// @Description Kicks a member from a team. Only the team owner can perform this action. +// @Tags Team +// @Param sh_session_id cookie string true "The authenticated session token/id" +// @Param team_id path string true "The ID of the team" +// @Param userId path string true "The ID of the user to be kicked" +// @Success 204 "Successfully kicked the team member" +// @Failure 400 {object} response.ErrorResponse "Bad Request: Missing or malformed parameters." +// @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." +// @Failure 403 {object} response.ErrorResponse "Forbidden: Requester is not allowed to perform this action." +// @Failure 500 {object} response.ErrorResponse "Something went wrong." +// +// @Router /teams/{teamId}/members/{userId} [delete] +func (h *TeamHandler) KickMemberFromTeam(w http.ResponseWriter, r *http.Request) { + // Implementation would go here + memberId, err := web.PathParamToUUID(r, "userId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_member_id", "The team member ID is malformed/missing.")) + return + } + + teamId, err := web.PathParamToUUID(r, "teamId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_team_id", "The team ID is malformed/missing.")) + return + } + + userId := ctxutils.GetUserIdFromCtx(r.Context()) + if userId == nil { + res.SendError(w, http.StatusUnauthorized, res.NewError("unauthorized", "User not authenticated")) + return + } + + err = h.teamService.KickMemberFromTeam(r.Context(), memberId, teamId, *userId) + if err != nil { + status, code, message := mapTeamServiceError(err) + res.SendError(w, status, res.NewError(code, message)) + return + } + + res.Send(w, http.StatusNoContent, nil) + +} + // Maps team service errors to HTTP status codes and messages func mapTeamServiceError(err error) (status int, code, message string) { switch { @@ -503,6 +549,8 @@ func mapTeamServiceError(err error) (status int, code, message string) { return http.StatusConflict, "team_full", "The team is already full." case errors.Is(err, repository.ErrTeamNotFound): return http.StatusNotFound, "team_not_found", "Team resource was not found." + case errors.Is(err, services.ErrKickOwnerSelf): + return http.StatusBadRequest, "cannot_kick_owner", "Team owners cannot kick themselves from their own team." default: return http.StatusInternalServerError, "internal_error", "Something went wrong." } diff --git a/apps/api/internal/services/teams.go b/apps/api/internal/services/teams.go index 4aefdb1c..db8844b7 100644 --- a/apps/api/internal/services/teams.go +++ b/apps/api/internal/services/teams.go @@ -23,6 +23,7 @@ var ( ErrUserNotTeamOwner = errors.New("user is not the team owner") ErrTeamFull = errors.New("team is full") ErrUserNotApplicantOrAttendee = errors.New("user is not an applicant or attendee for the event") + ErrKickOwnerSelf = errors.New("team owner cannot kick themselves") ) type TeamService struct { @@ -402,3 +403,23 @@ func (s *TeamService) RespondToJoinRequest(ctx context.Context, ownerId, request return err } } + +func (s *TeamService) KickMemberFromTeam(ctx context.Context, memberId, teamId, userId uuid.UUID) error { + team, err := s.teamRepo.GetByID(ctx, teamId) + if err != nil { + return err + } + + if *team.OwnerID != userId { + s.logger.Warn().Str("team_owner", team.OwnerID.String()).Str("user", userId.String()).Msg("User attempting to kick member is not the team owner") + return ErrUserNotTeamOwner + } + + // Prevent owner from kicking themselves + if memberId == userId { + s.logger.Warn().Str("team_owner", team.OwnerID.String()).Msg("Team owner attempting to kick themselves") + return ErrKickOwnerSelf + } + + return s.teamMemberRepo.Delete(ctx, team.ID, memberId) +} diff --git a/apps/web/src/features/Team/components/MyTeamCard.tsx b/apps/web/src/features/Team/components/MyTeamCard.tsx index 6605fb4f..e1ee9b37 100644 --- a/apps/web/src/features/Team/components/MyTeamCard.tsx +++ b/apps/web/src/features/Team/components/MyTeamCard.tsx @@ -5,14 +5,16 @@ import { useTeamActions } from "../hooks/useTeamActions"; import { Button } from "@/components/ui/Button"; import { toast } from "react-toastify"; import TablerDoorExit from "~icons/tabler/door-exit"; +import TablerCircleX from "~icons/tabler/circle-x"; interface Props { eventId: string; + userId: string; team: TeamWithMembers; } -export default function MyTeamCard({ eventId, team }: Props) { - const { leave } = useTeamActions(eventId); +export default function MyTeamCard({ eventId, userId, team }: Props) { + const { leave, kickTeamMember } = useTeamActions(eventId); const handleLeaveTeam = () => { leave.mutate(team.id, { @@ -25,6 +27,20 @@ export default function MyTeamCard({ eventId, team }: Props) { }); }; + const handleKickMember = (memberId: string) => { + kickTeamMember.mutate( + { teamId: team.id, memberId }, + { + onSuccess: () => { + toast.success("Member removed successfully."); + }, + onError: () => { + toast.error("Failed to remove member. Try again later."); + }, + }, + ); + }; + return (
@@ -63,14 +79,23 @@ export default function MyTeamCard({ eventId, team }: Props) { className="text-text-secondary flex items-center justify-between" > {member.name} - + + {/* Only show kick button if the viewer is the owner and the member is not the owner */} + {member.user_id !== team.owner_id && userId == team.owner_id && ( + + )} + + {/* Indicate the owner */} + {member.user_id === team.owner_id && ( + (Owner) + )} ))} diff --git a/apps/web/src/features/Team/hooks/useTeamActions.ts b/apps/web/src/features/Team/hooks/useTeamActions.ts index bb3cdfe9..44776b9e 100644 --- a/apps/web/src/features/Team/hooks/useTeamActions.ts +++ b/apps/web/src/features/Team/hooks/useTeamActions.ts @@ -29,6 +29,10 @@ async function createTeam(eventId: string, data: NewTeam) { } } +async function kickMember(teamId: string, memberId: string) { + await api.delete(`teams/${teamId}/members/${memberId}`); +} + export function useTeamActions(eventId: string) { const queryClient = useQueryClient(); @@ -48,5 +52,15 @@ export function useTeamActions(eventId: string) { }, }); - return { leave, create }; + const kickTeamMember = useMutation({ + mutationFn: ({ teamId, memberId }: { teamId: string; memberId: string }) => + kickMember(teamId, memberId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["myTeam", eventId], + }); + }, + }); + + return { leave, create, kickTeamMember }; } diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/my-team.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/my-team.tsx index f05483fe..a9cbe670 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/my-team.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/my-team.tsx @@ -40,6 +40,22 @@ function RouteComponent() { ); } + // Guard against missing user + if (!user || team.isError) { + return ( +
+ + My Team + +
+

+ Something went wrong. Please refresh and try again. +

+
+
+ ); + } + return (
@@ -48,7 +64,7 @@ function RouteComponent() {
{team.data ? ( - + ) : ( )} From e1f90f05d9a95c8d6771a00846891c6e7bdc1ee4 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Mon, 17 Nov 2025 12:02:38 -0500 Subject: [PATCH 2/9] chore: generate api docs and specs --- apps/api/docs/docs.go | 124 +++++++++++++++++++++ apps/api/docs/swagger.json | 124 +++++++++++++++++++++ apps/api/docs/swagger.yaml | 80 ++++++++++++++ apps/api/internal/api/handlers/auth.go | 2 +- apps/api/internal/api/handlers/teams.go | 2 +- apps/web/src/lib/openapi/schema.d.ts | 138 ++++++++++++++++++++++++ 6 files changed, 468 insertions(+), 2 deletions(-) diff --git a/apps/api/docs/docs.go b/apps/api/docs/docs.go index 30a3d15f..a333d440 100644 --- a/apps/api/docs/docs.go +++ b/apps/api/docs/docs.go @@ -1687,6 +1687,47 @@ const docTemplate = `{ ] } }, + "/events/{eventId}/application/download-resume": { + "get": { + "description": "This handler creates a presigned S3 URL with GET permission for the user's specific object, which is their uploaded resume. The client can use this URL to download the object.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Bad request/Malformed request." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: error handling download resume request" + } + }, + "summary": "Download the user's uploaded resume from their event application", + "tags": [ + "Application" + ] + } + }, "/events/{eventId}/application/save": { "post": { "description": "Save user's progress on the application. File/Upload fields are not saved.", @@ -3115,6 +3156,89 @@ const docTemplate = `{ ] } }, + "/teams/{teamId}/members/{userId}": { + "delete": { + "description": "Kicks a member from a team. Only the team owner can perform this action.", + "parameters": [ + { + "description": "The authenticated session token/id", + "in": "cookie", + "name": "sh_session_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The ID of the team", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The ID of the user to be kicked", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successfully kicked the team member" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Bad Request: Missing or malformed parameters." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Unauthenticated: Requester is not currently authenticated." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Forbidden: Requester is not allowed to perform this action." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Something went wrong." + } + }, + "summary": "Kick a member from a team", + "tags": [ + "Team" + ] + } + }, "/teams/{teamId}/pending-joins": { "get": { "description": "Retrieves a team's pending join requests. This is only allowed for the team's owner.", diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 25514775..6d771a82 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -1680,6 +1680,47 @@ ] } }, + "/events/{eventId}/application/download-resume": { + "get": { + "description": "This handler creates a presigned S3 URL with GET permission for the user's specific object, which is their uploaded resume. The client can use this URL to download the object.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Bad request/Malformed request." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: error handling download resume request" + } + }, + "summary": "Download the user's uploaded resume from their event application", + "tags": [ + "Application" + ] + } + }, "/events/{eventId}/application/save": { "post": { "description": "Save user's progress on the application. File/Upload fields are not saved.", @@ -3108,6 +3149,89 @@ ] } }, + "/teams/{teamId}/members/{userId}": { + "delete": { + "description": "Kicks a member from a team. Only the team owner can perform this action.", + "parameters": [ + { + "description": "The authenticated session token/id", + "in": "cookie", + "name": "sh_session_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The ID of the team", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The ID of the user to be kicked", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successfully kicked the team member" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Bad Request: Missing or malformed parameters." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Unauthenticated: Requester is not currently authenticated." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Forbidden: Requester is not allowed to perform this action." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Something went wrong." + } + }, + "summary": "Kick a member from a team", + "tags": [ + "Team" + ] + } + }, "/teams/{teamId}/pending-joins": { "get": { "description": "Retrieves a team's pending join requests. This is only allowed for the team's owner.", diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index e14c61a4..b1d598ef 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -1157,6 +1157,33 @@ paths: summary: Get Application By User and Event ID tags: - Application + /events/{eventId}/application/download-resume: + get: + description: This handler creates a presigned S3 URL with GET permission for + the user's specific object, which is their uploaded resume. The client can + use this URL to download the object. + responses: + "200": + content: + application/json: + schema: + type: string + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Bad request/Malformed request. + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Server Error: error handling download resume request' + summary: Download the user's uploaded resume from their event application + tags: + - Application /events/{eventId}/application/save: post: description: Save user's progress on the application. File/Upload fields are @@ -1876,6 +1903,59 @@ paths: summary: Get a team and its members by team id. tags: - Team + /teams/{teamId}/members/{userId}: + delete: + description: Kicks a member from a team. Only the team owner can perform this + action. + parameters: + - description: The authenticated session token/id + in: cookie + name: sh_session_id + required: true + schema: + type: string + - description: The ID of the team + in: path + name: team_id + required: true + schema: + type: string + - description: The ID of the user to be kicked + in: path + name: userId + required: true + schema: + type: string + responses: + "204": + description: Successfully kicked the team member + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Bad Request: Missing or malformed parameters.' + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Unauthenticated: Requester is not currently authenticated.' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Forbidden: Requester is not allowed to perform this action.' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Something went wrong. + summary: Kick a member from a team + tags: + - Team /teams/{teamId}/members/me: delete: description: Leaves a team if the requester is on the team. Depends on cookies diff --git a/apps/api/internal/api/handlers/auth.go b/apps/api/internal/api/handlers/auth.go index d6051afa..a5a38b39 100644 --- a/apps/api/internal/api/handlers/auth.go +++ b/apps/api/internal/api/handlers/auth.go @@ -39,7 +39,7 @@ func NewAuthHandler(authService *services.AuthService, cfg *config.Config, logge // @Success 200 {object} middleware.UserContext // @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." // @Failure 500 {object} response.ErrorResponse -// @Router /auth/me [get] +// @Router /auth/me [get] func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) { user, err := h.authService.GetMe(r.Context()) if err != nil { diff --git a/apps/api/internal/api/handlers/teams.go b/apps/api/internal/api/handlers/teams.go index 5f82c4ed..0e4c1c25 100644 --- a/apps/api/internal/api/handlers/teams.go +++ b/apps/api/internal/api/handlers/teams.go @@ -497,7 +497,7 @@ func (h *TeamHandler) RejectTeamJoinRequest(w http.ResponseWriter, r *http.Reque // @Tags Team // @Param sh_session_id cookie string true "The authenticated session token/id" // @Param team_id path string true "The ID of the team" -// @Param userId path string true "The ID of the user to be kicked" +// @Param userId path string true "The ID of the user to be kicked" // @Success 204 "Successfully kicked the team member" // @Failure 400 {object} response.ErrorResponse "Bad Request: Missing or malformed parameters." // @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." diff --git a/apps/web/src/lib/openapi/schema.d.ts b/apps/web/src/lib/openapi/schema.d.ts index 3fa2dc5c..a014dd38 100644 --- a/apps/web/src/lib/openapi/schema.d.ts +++ b/apps/web/src/lib/openapi/schema.d.ts @@ -529,6 +529,63 @@ export interface paths { patch?: never; trace?: never; }; + "/events/{eventId}/application/download-resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download the user's uploaded resume from their event application + * @description This handler creates a presigned S3 URL with GET permission for the user's specific object, which is their uploaded resume. The client can use this URL to download the object. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Bad request/Malformed request. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Server Error: error handling download resume request */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/events/{eventId}/application/save": { parameters: { query?: never; @@ -1669,6 +1726,87 @@ export interface paths { patch?: never; trace?: never; }; + "/teams/{teamId}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Kick a member from a team + * @description Kicks a member from a team. Only the team owner can perform this action. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the team */ + team_id: string; + /** @description The ID of the user to be kicked */ + userId: string; + }; + cookie: { + /** @description The authenticated session token/id */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description Successfully kicked the team member */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request: Missing or malformed parameters. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Unauthenticated: Requester is not currently authenticated. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Forbidden: Requester is not allowed to perform this action. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Something went wrong. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/teams/{teamId}/members/me": { parameters: { query?: never; From bda1c9941f67adc2525156d19be321bc1cfa60f4 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Mon, 17 Nov 2025 12:37:31 -0500 Subject: [PATCH 3/9] feat: Tooltip component + unstyled button --- .../components/ui/Button/Button.stories.tsx | 7 +++ apps/web/src/components/ui/Button/Button.tsx | 18 ++++++-- .../web/src/components/ui/Tooltip/Tooltip.tsx | 46 +++++++++++++++++++ apps/web/src/components/ui/Tooltip/index.ts | 1 + 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/ui/Tooltip/Tooltip.tsx create mode 100644 apps/web/src/components/ui/Tooltip/index.ts diff --git a/apps/web/src/components/ui/Button/Button.stories.tsx b/apps/web/src/components/ui/Button/Button.stories.tsx index 1ced4999..cf99955c 100644 --- a/apps/web/src/components/ui/Button/Button.stories.tsx +++ b/apps/web/src/components/ui/Button/Button.stories.tsx @@ -46,3 +46,10 @@ export const Danger: Story = { children: "Danger Button", }, }; + +export const Unstyled: Story = { + args: { + variant: "unstyled", + children: "Unstyled Button", + }, +}; diff --git a/apps/web/src/components/ui/Button/Button.tsx b/apps/web/src/components/ui/Button/Button.tsx index f2105c60..c1ee7e1a 100644 --- a/apps/web/src/components/ui/Button/Button.tsx +++ b/apps/web/src/components/ui/Button/Button.tsx @@ -6,7 +6,13 @@ import { import { tv } from "tailwind-variants"; export interface ButtonProps extends RACButtonProps { - variant?: "primary" | "secondary" | "danger" | "icon" | "skeleton"; + variant?: + | "primary" + | "secondary" + | "danger" + | "icon" + | "skeleton" + | "unstyled"; size?: "sm" | "md" | "lg" | "auto"; className?: string; } @@ -23,6 +29,7 @@ export const button = tv({ danger: "bg-button-danger hover:bg-button-danger-hover pressed:bg-button-danger-pressed text-white", icon: "border-0 p-1 flex items-center justify-center text-gray-600 hover:bg-black/[5%] pressed:bg-black/10 dark:text-zinc-400 dark:hover:bg-white/10 dark:pressed:bg-white/20 disabled:bg-transparent", + unstyled: "", }, isDisabled: { true: "cursor-not-allowed bg-gray-200 dark:bg-neutral-700 text-text-main/30 border-black/5 dark:border-white/5", @@ -34,7 +41,12 @@ export const button = tv({ lg: "py-2 px-4 text-lg", }, }, - + compoundVariants: [ + { + variant: "unstyled", + class: "", // override everything + }, + ], defaultVariants: { variant: "primary", size: "md", @@ -49,7 +61,7 @@ export function Button(props: ButtonProps) { button({ ...renderProps, variant: props.variant, - size: props.size, + size: props.variant === "unstyled" ? undefined : props.size, className, }), )} diff --git a/apps/web/src/components/ui/Tooltip/Tooltip.tsx b/apps/web/src/components/ui/Tooltip/Tooltip.tsx new file mode 100644 index 00000000..baff822d --- /dev/null +++ b/apps/web/src/components/ui/Tooltip/Tooltip.tsx @@ -0,0 +1,46 @@ +import type { TooltipTriggerProps } from "react-aria"; +import { + Tooltip as RAC_Tooltip, + TooltipTrigger, + type TooltipProps as RAC_ToolTipProps, +} from "react-aria-components"; + +// Use this to extend or customize trigger props in the future +// interface TriggerProps extends TooltipTriggerProps {} + +interface TooltipPanelProps extends Omit { + label: string; +} + +/** + * Usage: + * + * + * + */ +export interface TooltipProps { + triggerProps?: TooltipTriggerProps; + tooltipProps: TooltipPanelProps; + children: React.ReactNode; +} + +const Tooltip = ({ children, triggerProps, tooltipProps }: TooltipProps) => { + return ( + + {children} + + {tooltipProps.label} + + + ); +}; + +Tooltip.displayName = "Tooltip"; + +export { Tooltip }; diff --git a/apps/web/src/components/ui/Tooltip/index.ts b/apps/web/src/components/ui/Tooltip/index.ts new file mode 100644 index 00000000..46bb2f24 --- /dev/null +++ b/apps/web/src/components/ui/Tooltip/index.ts @@ -0,0 +1 @@ +export * from "./Tooltip"; From d9f459381d1dbb11c94525f4e8db8668de00cdf7 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Tue, 18 Nov 2025 11:19:31 -0500 Subject: [PATCH 4/9] feat: created final reminder email for shxi --- .../templates/FinalApplicationReminder.html | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 scripts/bulk-email/templates/FinalApplicationReminder.html diff --git a/scripts/bulk-email/templates/FinalApplicationReminder.html b/scripts/bulk-email/templates/FinalApplicationReminder.html new file mode 100644 index 00000000..0aeb6bad --- /dev/null +++ b/scripts/bulk-email/templates/FinalApplicationReminder.html @@ -0,0 +1,101 @@ + + + + + + + Urgent: Final Call to Complete Your SwampHacks XI Application! ๐ŸŠ + + + + + + + +
+ + + + + + + + + + + + + + +
+ SwampHacks XI Banner +
+

Hey Hacker,

+ +

+ This is your final reminder โ€” your SwampHacks XI application is still + incomplete, + and the deadline is approaching fast. +

+ +

+ Applications close in just 3 days. Once the deadline passes, we wonโ€™t be able to accept + late submissions. +

+ +

+ SwampHacks XI is happening from January 23โ€“25, 2026, and we want to make sure you donโ€™t + miss out on an incredible weekend of building, experimenting, and creating alongside hackers from across + the country. +

+ + + +

What Awaits You at SwampHacks XI

+
    +
  • 36 hours of building, problem-solving, and rapid + prototyping.
  • +
  • Workshops, mentorship, and exciting prize tracks.
  • +
  • A supportive and inclusive space for hackers at every + level.
  • +
+ +

+ This is your moment. Lock in your spot before itโ€™s too late. +

+

+ โ€” The SwampHacks Team ๐ŸŠ +

+

+ Need more info? Visit SwampHacks.com. +

+
+ + Discord + + + Instagram + + + LinkedIn + +
+
+ + + \ No newline at end of file From 140e47f04a96d7bdb2cb5685d2994e3fee551f90 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Tue, 18 Nov 2025 11:43:33 -0500 Subject: [PATCH 5/9] feat: tooltips for leave and kick member --- .../features/Team/components/MyTeamCard.tsx | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/apps/web/src/features/Team/components/MyTeamCard.tsx b/apps/web/src/features/Team/components/MyTeamCard.tsx index e1ee9b37..0bdecdd1 100644 --- a/apps/web/src/features/Team/components/MyTeamCard.tsx +++ b/apps/web/src/features/Team/components/MyTeamCard.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button"; import { toast } from "react-toastify"; import TablerDoorExit from "~icons/tabler/door-exit"; import TablerCircleX from "~icons/tabler/circle-x"; +import { Tooltip } from "@/components/ui/Tooltip"; interface Props { eventId: string; @@ -46,13 +47,23 @@ export default function MyTeamCard({ eventId, userId, team }: Props) {

{team.name}

- + +
handleKickMember(member.user_id)} - className="ml-2 p-1 hover:text-red-500 cursor-pointer transition-all duration-150" - aria-label={`Remove ${member.name}`} + - - + + )} {/* Indicate the owner */} {member.user_id === team.owner_id && ( - (Owner) + + (Owner) + )} ))} From 3189c2955514c6e5ea9904deff93940495402865 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Tue, 18 Nov 2025 12:38:04 -0500 Subject: [PATCH 6/9] feat: some fun random additions --- .../Settings/components/SettingsPage.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/src/features/Settings/components/SettingsPage.tsx b/apps/web/src/features/Settings/components/SettingsPage.tsx index cb0733be..f874c3a7 100644 --- a/apps/web/src/features/Settings/components/SettingsPage.tsx +++ b/apps/web/src/features/Settings/components/SettingsPage.tsx @@ -229,6 +229,25 @@ export function SettingsPage({ logout }: { logout: () => void }) { Log Out
+ + {/* Versioning Footer */} +
From 273939ddf13b68ebcc5c0746058cb0e04e9f3cb7 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Tue, 18 Nov 2025 12:38:47 -0500 Subject: [PATCH 7/9] chore: remove weird comment --- apps/web/src/features/Settings/components/SettingsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/features/Settings/components/SettingsPage.tsx b/apps/web/src/features/Settings/components/SettingsPage.tsx index f874c3a7..847cdc4e 100644 --- a/apps/web/src/features/Settings/components/SettingsPage.tsx +++ b/apps/web/src/features/Settings/components/SettingsPage.tsx @@ -230,7 +230,6 @@ export function SettingsPage({ logout }: { logout: () => void }) { - {/* Versioning Footer */}
Date: Wed, 19 Nov 2025 14:56:25 -0500 Subject: [PATCH 8/9] Update table url state management to human readable form --- .../EventAdmin/hooks/useUrlTableState.ts | 152 ++++++++++-------- 1 file changed, 81 insertions(+), 71 deletions(-) diff --git a/apps/web/src/features/EventAdmin/hooks/useUrlTableState.ts b/apps/web/src/features/EventAdmin/hooks/useUrlTableState.ts index ded9856b..6f21bc0f 100644 --- a/apps/web/src/features/EventAdmin/hooks/useUrlTableState.ts +++ b/apps/web/src/features/EventAdmin/hooks/useUrlTableState.ts @@ -4,51 +4,11 @@ import type { ColumnFiltersState, SortingState, PaginationState, -} from "@/components/ui/Table"; +} from "@tanstack/react-table"; -interface TableState { - filters: ColumnFiltersState; - sorting: SortingState; - pagination: PaginationState; -} - -// Helper functions for handling URL filter encoding -function parseTableState(encodedString: string | undefined): TableState { - const defaults: TableState = { - filters: [], - sorting: [], - pagination: { - pageIndex: 0, - pageSize: 10, - }, - }; - if (encodedString) { - try { - const decoded = atob(encodedString); - const parsed = JSON.parse(decoded); - return { ...defaults, ...parsed }; - } catch (e) { - console.error("Failed to decode filters from URL:", e); - } - } - return defaults; -} +const RESERVED_KEYS = ["page", "limit", "sort"]; -function encodeTableState(state: TableState): string { - if ( - state.filters.length === 0 && - state.sorting.length === 0 && - state.pagination.pageIndex === 0 && - state.pagination.pageSize === 10 - ) { - return ""; // Return empty string to clear URL param - } - return btoa(JSON.stringify(state)); -} - -interface UseUrlStateProps< - TSearch extends { tableState?: string | undefined }, -> { +interface UseUrlStateProps { search: TSearch; navigate: (options: { search: (prev: TSearch) => TSearch; @@ -57,47 +17,97 @@ interface UseUrlStateProps< debounceMs?: number; } -export function useUrlTableState< - TSearch extends { tableState?: string | undefined }, ->({ search, navigate, debounceMs = 300 }: UseUrlStateProps) { - const initialState = useMemo( - () => parseTableState(search.tableState), - [search.tableState], - ); +export function useUrlTableState>({ + search, + navigate, + debounceMs = 300, +}: UseUrlStateProps) { + const initialState = useMemo(() => { + const pageIndex = Number(search.page) ? Number(search.page) - 1 : 0; + const pageSize = Number(search.limit) ? Number(search.limit) : 10; + + let sorting: SortingState = []; + if (search.sort && typeof search.sort === "string") { + const [id, dir] = search.sort.split("."); + sorting = [{ id, desc: dir === "desc" }]; + } + + // Map any non-reserved key to a column filter + const filters: ColumnFiltersState = Object.keys(search) + .filter( + (key) => !RESERVED_KEYS.includes(key) && search[key] !== undefined, + ) + .map((key) => ({ + id: key, + value: search[key], + })); + + return { + pagination: { pageIndex, pageSize }, + sorting, + filters, + }; + }, [search]); const [columnFilters, setColumnFilters] = useState( initialState.filters, ); - const [sorting, setSorting] = useState(initialState.sorting); - const [pagination, setPagination] = useState( initialState.pagination, ); - const debouncedUrlUpdate = useMemo( + const updateUrl = useMemo( () => debounce( ( - filters: ColumnFiltersState, - sort: SortingState, - pagination: PaginationState, + currentFilters: ColumnFiltersState, + currentSorting: SortingState, + currentPagination: PaginationState, currentSearch: TSearch, ) => { - const newState: TableState = { filters, sorting: sort, pagination }; - const newSearchState = encodeTableState(newState); - - const newSearchParam = newSearchState ? newSearchState : undefined; - - if (newSearchParam !== currentSearch.tableState) { - navigate({ - search: (prev) => ({ - ...prev, - tableState: newSearchParam, - }), - replace: true, - }); + const newParams: Record = {}; + + if (currentPagination.pageIndex > 0) { + newParams.page = currentPagination.pageIndex + 1; + } else { + newParams.page = undefined; // Remove from URL + } + + if (currentPagination.pageSize !== 10) { + newParams.limit = currentPagination.pageSize; + } else { + newParams.limit = undefined; } + + if (currentSorting.length > 0) { + const { id, desc } = currentSorting[0]; + newParams.sort = `${id}.${desc ? "desc" : "asc"}`; + } else { + newParams.sort = undefined; + } + + // Remove "stale" filters that aren't in url params + Object.keys(currentSearch).forEach((key) => { + if (!RESERVED_KEYS.includes(key)) { + newParams[key] = undefined; + } + }); + + currentFilters.forEach((filter) => { + if (filter.value !== undefined && filter.value !== "") { + newParams[filter.id] = filter.value; + } + }); + + // Check if anything actually changed to avoid redundant navigations + navigate({ + search: (prev) => ({ + ...prev, + ...newParams, + }), + replace: true, + }); }, debounceMs, ), @@ -105,8 +115,8 @@ export function useUrlTableState< ); useEffect(() => { - debouncedUrlUpdate(columnFilters, sorting, pagination, search); - }, [columnFilters, sorting, pagination, search, debouncedUrlUpdate]); + updateUrl(columnFilters, sorting, pagination, search); + }, [columnFilters, sorting, pagination, search, updateUrl]); return { columnFilters, From 2b29588c4a7b73387b1117d4306efb65f068d8c6 Mon Sep 17 00:00:00 2001 From: Alexander Wang <98280966+AlexanderWangY@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:29:38 -0500 Subject: [PATCH 9/9] Add pagination to fetchEventTeams function --- apps/web/src/features/Team/hooks/useEventTeams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/features/Team/hooks/useEventTeams.ts b/apps/web/src/features/Team/hooks/useEventTeams.ts index 69ef8152..514e7f40 100644 --- a/apps/web/src/features/Team/hooks/useEventTeams.ts +++ b/apps/web/src/features/Team/hooks/useEventTeams.ts @@ -8,7 +8,7 @@ export type TeamsWithMembers = export function useEventTeams(eventId: string, limit: number, offset: number) { async function fetchEventTeams(): Promise { const result = await api - .get(`events/${eventId}/teams`) + .get(`events/${eventId}/teams?limit=${limit}&offset=${offset}`) .json(); return result ?? null; }