diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 0967ef4..168d9d2 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1 +1,3 @@ -{} +{ + "endOfLine": "auto" +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f6fdef2..af62e2f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -806,7 +806,8 @@ body { position: relative; transition: border-color 0.12s ease, - transform 0.12s ease; + opacity 0.22s ease, + transform 0.22s cubic-bezier(0.2, 0, 0.2, 1); } .apps-kanban-card.has-started-action { padding-right: 30px; @@ -817,6 +818,12 @@ body { .apps-kanban-card.is-dragging { visibility: hidden; } +.apps-kanban-card.is-deleting { + opacity: 0; + transform: translate3d(0, -4px, 0) scale(0.985); + pointer-events: none; + will-change: opacity, transform; +} .apps-started-action { position: absolute; top: -1px; diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index e29d930..76a4b23 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -23,6 +23,19 @@ type RecruitmentCycleRecord = { updatedAt: Date; }; +type ApplicationRecord = { + _id: ObjectId; + userId: ObjectId; + jobId: string; + status: ApplicationStatus; + startedAt: Date; + updatedAt: Date; + jobSnapshot: ApplicationJobSnapshot; + cycleId?: string; + notes?: string; + starred?: boolean; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function requireUserId(session: any) { const id = (session?.user as { id?: string } | undefined)?.id; @@ -44,6 +57,26 @@ function serializeCycle( }; } +function serializeApplication( + doc: ApplicationRecord, + fallbackLogo?: string, +): DbApplication { + return { + _id: doc._id.toString(), + jobId: doc.jobId, + status: doc.status, + startedAt: new Date(doc.startedAt).toISOString(), + updatedAt: new Date(doc.updatedAt).toISOString(), + jobSnapshot: { + ...doc.jobSnapshot, + logo: doc.jobSnapshot.logo ?? fallbackLogo, + }, + cycleId: doc.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + notes: doc.notes ?? undefined, + starred: doc.starred ?? false, + }; +} + async function ensureDefaultCycle(db: Db, userObjectId: ObjectId) { const now = new Date(); @@ -210,7 +243,7 @@ export async function listApplications(): Promise { const db = client.db(process.env.MONGODB_DATABASE || "default"); const docs = await db - .collection("applications") + .collection("applications") .find({ userId: new ObjectId(userId) }) .sort({ updatedAt: -1 }) .limit(500) @@ -242,20 +275,7 @@ export async function listApplications(): Promise { } } - return docs.map((d) => ({ - _id: d._id.toString(), - jobId: d.jobId, - status: d.status, - startedAt: new Date(d.startedAt).toISOString(), - updatedAt: new Date(d.updatedAt).toISOString(), - jobSnapshot: { - ...d.jobSnapshot, - logo: d.jobSnapshot.logo ?? logoMap.get(d.jobId), - }, - cycleId: d.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, - notes: d.notes ?? undefined, - starred: d.starred ?? false, - })) as DbApplication[]; + return docs.map((d) => serializeApplication(d, logoMap.get(d.jobId))); } export async function addApplication( @@ -300,6 +320,52 @@ export async function deleteApplication(jobId: string) { return { ok: true }; } +export async function restoreDeletedApplication( + application: DbApplication, +): Promise { + const session = await getServerSession(getAuthOptions()); + const userId = requireUserId(session); + const userObjectId = new ObjectId(userId); + + const client = await getMongoClientPromise(); + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const collection = db.collection("applications"); + const startedAt = new Date(application.startedAt); + const updatedAt = new Date(application.updatedAt); + const hasNotes = + typeof application.notes === "string" && + application.notes.trim().length > 0; + + await collection.updateOne( + { userId: userObjectId, jobId: application.jobId }, + { + $set: { + jobId: application.jobId, + status: application.status, + startedAt, + updatedAt, + jobSnapshot: application.jobSnapshot, + cycleId: application.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + starred: application.starred ?? false, + ...(hasNotes ? { notes: application.notes } : {}), + }, + ...(hasNotes ? {} : { $unset: { notes: "" } }), + $setOnInsert: { + userId: userObjectId, + }, + }, + { upsert: true }, + ); + + const restored = await collection.findOne({ + userId: userObjectId, + jobId: application.jobId, + }); + + if (!restored) throw new Error("Application could not be restored"); + return serializeApplication(restored); +} + export async function createCustomApplication( title: string, companyName: string, diff --git a/frontend/src/components/applications/applications-kanban.tsx b/frontend/src/components/applications/applications-kanban.tsx index 4a02bc6..0b92b5e 100644 --- a/frontend/src/components/applications/applications-kanban.tsx +++ b/frontend/src/components/applications/applications-kanban.tsx @@ -90,7 +90,7 @@ type Props = { oldStatus: ApplicationStatus, next: ApplicationStatus, ) => Promise; - onDelete: (appId: string, jobId: string) => void; + onDelete: (appId: string, jobId: string) => Promise; onSaveNotes: (jobId: string, notes: string) => Promise; onCreateInStage: ( title: string, @@ -934,9 +934,18 @@ function KanbanCard({ ); const [movingTo, setMovingTo] = useState(null); const [moveFeedback, setMoveFeedback] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const mountedRef = useRef(true); const currentStage = stages.find((stage) => stage.name === app.status); const moveTargets = stages.filter((stage) => stage.name !== app.status); + useEffect( + () => () => { + mountedRef.current = false; + }, + [], + ); + const setCardNodeRef = useCallback( (node: HTMLElement | null) => { cardRef.current = node; @@ -1164,7 +1173,8 @@ function KanbanCard({ (app.status === "STARTED" ? " has-started-action" : "") + (density === "compact" ? " apps-kanban-card--compact" : "") + (!mobileDragDisabled && isDragging ? " is-dragging" : "") + - (mobileDragDisabled ? " is-mobile-drag-disabled" : "") + (mobileDragDisabled ? " is-mobile-drag-disabled" : "") + + (isDeleting ? " is-deleting" : "") } style={style} onClick={() => url && window.open(url, "_blank", "noreferrer")} @@ -1267,9 +1277,15 @@ function KanbanCard({ type="button" className="apps-icon-btn" aria-label="Delete" + disabled={isDeleting} onClick={(e) => { e.stopPropagation(); - onDelete(app._id, app.jobId); + if (isDeleting) return; + setNotesOpen(false); + setIsDeleting(true); + void onDelete(app._id, app.jobId).catch(() => { + if (mountedRef.current) setIsDeleting(false); + }); }} > diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index dd2c6ac..52d6cfe 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -28,6 +28,7 @@ import { IconSearch, IconTrash, } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; import Link from "next/link"; import { ApplicationStatus, @@ -47,6 +48,7 @@ import { deleteRecruitmentCycle, deleteApplication, renameRecruitmentCycle, + restoreDeletedApplication, syncLocalApplications, toggleApplicationStar, updateApplicationNotes, @@ -66,6 +68,7 @@ const SORT_STORAGE_KEY = "mp:apps:kanban-sort:v1"; const DENSITY_STORAGE_KEY = "mp:apps:kanban-density:v1"; const STAGE_ORDER_STORAGE_KEY = "mp:apps:stage-order:v1"; const MAC_YELLOW = "#ffe22f"; +const DELETE_CARD_FADE_MS = 220; function readSort(): KanbanSort { if (typeof window === "undefined") return "newest"; @@ -309,11 +312,105 @@ export default function MyApplicationsClient({ async function handleDelete(appId: string, jobId: string) { const removed = apps.find((a) => a._id === appId); - setApps((prev) => prev.filter((a) => a._id !== appId)); + if (!removed) throw new Error("Application not found"); + + const fadePromise = new Promise<"fade">((resolve) => { + window.setTimeout(() => resolve("fade"), DELETE_CARD_FADE_MS); + }); + const deleteResultPromise = deleteApplication(jobId).then( + () => ({ status: "deleted" as const }), + (error) => ({ status: "failed" as const, error }), + ); + try { - await deleteApplication(jobId); - } catch { - if (removed) setApps((prev) => [removed, ...prev]); + const firstResult = await Promise.race([ + fadePromise, + deleteResultPromise, + ]); + + if (firstResult !== "fade" && firstResult.status === "failed") { + throw firstResult.error; + } + + await fadePromise; + setApps((prev) => prev.filter((a) => a._id !== appId)); + + const deleteResult = + firstResult === "fade" ? await deleteResultPromise : firstResult; + + if (deleteResult.status === "failed") { + setApps((prev) => + prev.some((app) => app._id === removed._id) + ? prev + : [removed, ...prev], + ); + throw deleteResult.error; + } + + const notificationId = `deleted-application-${removed._id}`; + notifications.show({ + id: notificationId, + position: "top-right", + autoClose: 7000, + withCloseButton: true, + color: "accent", + message: ( + + + Deleted{" "} + + {removed.jobSnapshot.title} + {" "} + at{" "} + + {removed.jobSnapshot.companyName} + + + + + ), + }); + } catch (error) { + notifications.show({ + position: "top-right", + autoClose: 3000, + color: "red", + message: "Couldn't delete application. Try again.", + }); + throw error; } }