fix: 🎨 Saved jobs + Cover letters + Discover pages neo-brutalism
Browse files
frontend/src/app/(dashboard)/saved/page.tsx
CHANGED
|
@@ -10,144 +10,70 @@ export default function SavedJobsPage() {
|
|
| 10 |
const queryClient = useQueryClient();
|
| 11 |
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
| 12 |
|
| 13 |
-
const { data: folders } = useQuery({
|
| 14 |
-
queryKey: ["bookmark-folders"],
|
| 15 |
-
queryFn: async () => {
|
| 16 |
-
const res = await apiClient.get("/bookmarks/folders");
|
| 17 |
-
return res.data;
|
| 18 |
-
},
|
| 19 |
-
});
|
| 20 |
-
|
| 21 |
const { data: savedJobs, isLoading } = useQuery({
|
| 22 |
queryKey: ["saved-jobs", selectedFolder],
|
| 23 |
-
queryFn: async () => {
|
| 24 |
-
const params: any = {};
|
| 25 |
-
if (selectedFolder) params.folder_id = selectedFolder;
|
| 26 |
-
const res = await apiClient.get("/saved-jobs", { params });
|
| 27 |
-
return res.data;
|
| 28 |
-
},
|
| 29 |
});
|
| 30 |
|
| 31 |
-
const unsave = useMutation({
|
| 32 |
-
mutationFn: async (jobId: string) => {
|
| 33 |
-
await apiClient.delete(`/jobs/${jobId}/save`);
|
| 34 |
-
},
|
| 35 |
-
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-jobs"] }),
|
| 36 |
-
});
|
| 37 |
|
| 38 |
-
const
|
| 39 |
-
if (!min && !max) return null;
|
| 40 |
-
const fmt = (n: number) => `$${(n / 1000).toFixed(0)}k`;
|
| 41 |
-
if (min && max) return `${fmt(min)} - ${fmt(max)}`;
|
| 42 |
-
if (max) return `Up to ${fmt(max)}`;
|
| 43 |
-
return `From ${fmt(min!)}`;
|
| 44 |
-
};
|
| 45 |
|
| 46 |
return (
|
| 47 |
<div className="space-y-6">
|
| 48 |
-
<div
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
<p className="text-muted-foreground">
|
| 52 |
-
Jobs you've bookmarked for later review
|
| 53 |
-
</p>
|
| 54 |
-
</div>
|
| 55 |
</div>
|
| 56 |
|
| 57 |
<div className="flex gap-6">
|
| 58 |
-
{/*
|
| 59 |
-
<div className="w-
|
| 60 |
-
<button
|
| 61 |
-
|
| 62 |
-
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm ${
|
| 63 |
-
!selectedFolder ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground hover:text-foreground"
|
| 64 |
-
}`}
|
| 65 |
-
>
|
| 66 |
-
<Bookmark className="h-4 w-4" /> All Saved
|
| 67 |
-
<span className="ml-auto text-xs">{savedJobs?.total || 0}</span>
|
| 68 |
</button>
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
key={folder.id}
|
| 73 |
-
onClick={() => setSelectedFolder(folder.id)}
|
| 74 |
-
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm ${
|
| 75 |
-
selectedFolder === folder.id ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground hover:text-foreground"
|
| 76 |
-
}`}
|
| 77 |
-
>
|
| 78 |
-
<Folder className="h-4 w-4" style={{ color: folder.color || undefined }} />
|
| 79 |
-
<span className="truncate">{folder.name}</span>
|
| 80 |
-
<span className="ml-auto text-xs">{folder.job_count}</span>
|
| 81 |
</button>
|
| 82 |
))}
|
| 83 |
-
|
| 84 |
-
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-xs text-muted-foreground hover:text-primary">
|
| 85 |
-
<FolderPlus className="h-3.5 w-3.5" /> New Folder
|
| 86 |
-
</button>
|
| 87 |
</div>
|
| 88 |
|
| 89 |
-
{/*
|
| 90 |
<div className="flex-1">
|
| 91 |
{isLoading ? (
|
| 92 |
-
<div className="space-y-3">
|
| 93 |
-
{[...Array(4)].map((_, i) => (
|
| 94 |
-
<div key={i} className="h-24 animate-pulse rounded-lg border bg-muted" />
|
| 95 |
-
))}
|
| 96 |
-
</div>
|
| 97 |
) : savedJobs?.jobs?.length > 0 ? (
|
| 98 |
<div className="space-y-3">
|
| 99 |
{savedJobs.jobs.map((item: any) => (
|
| 100 |
-
<div key={item.saved_id} className="
|
| 101 |
<div className="flex items-start justify-between">
|
| 102 |
<div className="flex-1">
|
| 103 |
-
<Link href={`/jobs/${item.job_id}`} className="text-sm font-
|
| 104 |
-
|
| 105 |
-
<
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
{item.
|
| 109 |
-
|
| 110 |
-
)}
|
| 111 |
-
{item.remote_type && (
|
| 112 |
-
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-primary">{item.remote_type}</span>
|
| 113 |
-
)}
|
| 114 |
-
{formatSalary(item.salary_min, item.salary_max) && (
|
| 115 |
-
<span className="font-medium text-foreground">{formatSalary(item.salary_min, item.salary_max)}</span>
|
| 116 |
-
)}
|
| 117 |
-
<span className="flex items-center gap-1">
|
| 118 |
-
<Clock className="h-3 w-3" />
|
| 119 |
-
Saved {new Date(item.saved_at).toLocaleDateString()}
|
| 120 |
-
</span>
|
| 121 |
</div>
|
| 122 |
-
{item.notes && (
|
| 123 |
-
<p className="mt-2 text-xs italic text-muted-foreground">"{item.notes}"</p>
|
| 124 |
-
)}
|
| 125 |
</div>
|
| 126 |
-
<div className="flex
|
| 127 |
-
<a href={item.source_url} target="_blank" className="
|
| 128 |
-
|
| 129 |
-
</a>
|
| 130 |
-
<button
|
| 131 |
-
onClick={() => unsave.mutate(item.job_id)}
|
| 132 |
-
className="rounded-md p-1.5 text-muted-foreground hover:text-destructive"
|
| 133 |
-
title="Remove"
|
| 134 |
-
>
|
| 135 |
-
<Trash2 className="h-3.5 w-3.5" />
|
| 136 |
-
</button>
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
</div>
|
| 140 |
))}
|
| 141 |
</div>
|
| 142 |
) : (
|
| 143 |
-
<div className="
|
| 144 |
-
<Bookmark className="mx-auto h-10 w-10 text-muted-foreground" />
|
| 145 |
-
<p className="mt-3 text-sm text-muted-foreground">
|
| 146 |
-
|
| 147 |
-
</p>
|
| 148 |
-
<Link href="/jobs" className="mt-3 inline-block text-sm text-primary hover:underline">
|
| 149 |
-
Browse Jobs →
|
| 150 |
-
</Link>
|
| 151 |
</div>
|
| 152 |
)}
|
| 153 |
</div>
|
|
|
|
| 10 |
const queryClient = useQueryClient();
|
| 11 |
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
| 12 |
|
| 13 |
+
const { data: folders } = useQuery({ queryKey: ["bookmark-folders"], queryFn: async () => { const r = await apiClient.get("/bookmarks/folders"); return r.data; } });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const { data: savedJobs, isLoading } = useQuery({
|
| 15 |
queryKey: ["saved-jobs", selectedFolder],
|
| 16 |
+
queryFn: async () => { const params: any = {}; if (selectedFolder) params.folder_id = selectedFolder; const r = await apiClient.get("/saved-jobs", { params }); return r.data; },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
});
|
| 18 |
|
| 19 |
+
const unsave = useMutation({ mutationFn: async (jobId: string) => { await apiClient.delete(`/jobs/${jobId}/save`); }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-jobs"] }) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
const fmt = (min: number | null, max: number | null) => { if (!min && !max) return null; const f = (n: number) => `$${(n/1000).toFixed(0)}k`; if (min && max) return `${f(min)}-${f(max)}`; return max ? `Up to ${f(max)}` : `From ${f(min!)}`; };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
return (
|
| 24 |
<div className="space-y-6">
|
| 25 |
+
<div>
|
| 26 |
+
<h1 className="text-3xl md:text-4xl" style={{ fontFamily: "var(--font-display)" }}>Saved Jobs</h1>
|
| 27 |
+
<p className="text-[var(--muted-foreground)] font-medium mt-1">Jobs bookmarked for later review</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
|
| 30 |
<div className="flex gap-6">
|
| 31 |
+
{/* Folders */}
|
| 32 |
+
<div className="w-44 space-y-1">
|
| 33 |
+
<button onClick={() => setSelectedFolder(null)} className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-bold uppercase tracking-wide ${!selectedFolder ? "bg-[var(--primary)] text-[var(--primary-foreground)] border-2 border-[var(--border)]" : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"}`} style={!selectedFolder ? { boxShadow: "var(--neo-xs)" } : {}}>
|
| 34 |
+
<Bookmark className="h-3.5 w-3.5" /> All <span className="ml-auto text-[10px]">{savedJobs?.total || 0}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
</button>
|
| 36 |
+
{folders?.map((f: any) => (
|
| 37 |
+
<button key={f.id} onClick={() => setSelectedFolder(f.id)} className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-bold uppercase tracking-wide ${selectedFolder === f.id ? "bg-[var(--primary)] text-[var(--primary-foreground)] border-2 border-[var(--border)]" : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"}`} style={selectedFolder === f.id ? { boxShadow: "var(--neo-xs)" } : {}}>
|
| 38 |
+
<Folder className="h-3.5 w-3.5" style={{ color: f.color || undefined }} /> <span className="truncate">{f.name}</span> <span className="ml-auto text-[10px]">{f.job_count}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</button>
|
| 40 |
))}
|
| 41 |
+
<button className="flex w-full items-center gap-2 px-3 py-2 text-[10px] font-bold text-[var(--muted-foreground)] hover:text-[var(--primary)] uppercase"><FolderPlus className="h-3 w-3" /> New Folder</button>
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
+
{/* Jobs */}
|
| 45 |
<div className="flex-1">
|
| 46 |
{isLoading ? (
|
| 47 |
+
<div className="space-y-3">{[...Array(4)].map((_, i) => <div key={i} className="h-20 border-2 border-[var(--border)] bg-[var(--muted)] animate-pulse" />)}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
) : savedJobs?.jobs?.length > 0 ? (
|
| 49 |
<div className="space-y-3">
|
| 50 |
{savedJobs.jobs.map((item: any) => (
|
| 51 |
+
<div key={item.saved_id} className="neo-card p-4 group">
|
| 52 |
<div className="flex items-start justify-between">
|
| 53 |
<div className="flex-1">
|
| 54 |
+
<Link href={`/jobs/${item.job_id}`} className="text-sm font-bold hover:text-[var(--primary)] text-[var(--foreground)]">{item.title}</Link>
|
| 55 |
+
<p className="text-xs font-bold text-[var(--muted-foreground)]">{item.company_name}</p>
|
| 56 |
+
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] text-[var(--muted-foreground)] font-medium">
|
| 57 |
+
{item.locations?.[0]?.raw && <span className="flex items-center gap-1"><MapPin className="h-3 w-3" />{item.locations[0].raw}</span>}
|
| 58 |
+
{item.remote_type && <span className="neo-badge bg-[var(--accent)] text-[var(--accent-foreground)]">{item.remote_type}</span>}
|
| 59 |
+
{fmt(item.salary_min, item.salary_max) && <span className="font-extrabold text-[var(--foreground)]">{fmt(item.salary_min, item.salary_max)}</span>}
|
| 60 |
+
<span className="flex items-center gap-1"><Clock className="h-3 w-3" />Saved {new Date(item.saved_at).toLocaleDateString()}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
+
{item.notes && <p className="mt-2 text-[10px] italic text-[var(--muted-foreground)]">"{item.notes}"</p>}
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
+
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 65 |
+
<a href={item.source_url} target="_blank" className="neo-interactive bg-[var(--card)] p-1.5"><ExternalLink className="h-3 w-3 text-[var(--foreground)]" /></a>
|
| 66 |
+
<button onClick={() => unsave.mutate(item.job_id)} className="neo-interactive bg-[var(--card)] p-1.5"><Trash2 className="h-3 w-3 text-[var(--destructive)]" /></button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
</div>
|
| 70 |
))}
|
| 71 |
</div>
|
| 72 |
) : (
|
| 73 |
+
<div className="neo-card p-8 text-center border-dashed">
|
| 74 |
+
<Bookmark className="mx-auto h-10 w-10 text-[var(--muted-foreground)]" />
|
| 75 |
+
<p className="mt-3 text-sm text-[var(--muted-foreground)] font-medium">{selectedFolder ? "No jobs in this folder" : "No saved jobs yet"}</p>
|
| 76 |
+
<Link href="/jobs" className="mt-2 inline-block text-sm font-bold text-[var(--primary)]">Browse Jobs →</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
)}
|
| 79 |
</div>
|