Skip to content

Commit 2663960

Browse files
committed
feat(saved-searches): API ricerche salvate CRUD con toggle alert + pagina /saved-searches con badge nuovi e form inline
2 parents 7edada3 + b66c995 commit 2663960

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* API Saved Searches — ricerche salvate CRUD, toggle notifiche
3+
*/
4+
import { NextResponse } from 'next/server';
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import os from 'node:os';
8+
import crypto from 'node:crypto';
9+
10+
export const dynamic = 'force-dynamic';
11+
12+
type SavedSearch = { id: string; name: string; query: string; filters: Record<string, string>; alertEnabled: boolean; frequency: string; newCount: number; lastRun: number; createdAt: number };
13+
14+
const SS_PATH = path.join(os.homedir(), '.jht', 'saved-searches.json');
15+
16+
function load(): SavedSearch[] {
17+
try { return JSON.parse(fs.readFileSync(SS_PATH, 'utf-8')); }
18+
catch { return generateSample(); }
19+
}
20+
21+
function save(data: SavedSearch[]): void {
22+
const dir = path.dirname(SS_PATH);
23+
fs.mkdirSync(dir, { recursive: true });
24+
const tmp = SS_PATH + '.tmp';
25+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
26+
fs.renameSync(tmp, SS_PATH);
27+
}
28+
29+
function generateSample(): SavedSearch[] {
30+
const now = Date.now(); const day = 86400000;
31+
return [
32+
{ id: 'ss-001', name: 'Full Stack Remote', query: 'Full Stack Developer', filters: { location: 'Remote', salary: '50k+' }, alertEnabled: true, frequency: 'daily', newCount: 3, lastRun: now - 12 * 3600000, createdAt: now - 30 * day },
33+
{ id: 'ss-002', name: 'React Milano', query: 'React Developer', filters: { location: 'Milano' }, alertEnabled: true, frequency: 'daily', newCount: 1, lastRun: now - 6 * 3600000, createdAt: now - 20 * day },
34+
{ id: 'ss-003', name: 'Backend Node.js', query: 'Node.js Backend', filters: { salary: '45k+', type: 'full-time' }, alertEnabled: false, frequency: 'weekly', newCount: 0, lastRun: now - 3 * day, createdAt: now - 15 * day },
35+
{ id: 'ss-004', name: 'Platform Engineer EU', query: 'Platform Engineer', filters: { location: 'EU', remote: 'true' }, alertEnabled: true, frequency: 'realtime', newCount: 5, lastRun: now - 3600000, createdAt: now - 10 * day },
36+
{ id: 'ss-005', name: 'DevOps Torino', query: 'DevOps Engineer', filters: { location: 'Torino' }, alertEnabled: false, frequency: 'weekly', newCount: 0, lastRun: now - 7 * day, createdAt: now - 5 * day },
37+
];
38+
}
39+
40+
export async function GET() {
41+
const searches = load().sort((a, b) => b.lastRun - a.lastRun);
42+
const withAlerts = searches.filter(s => s.alertEnabled).length;
43+
const totalNew = searches.reduce((s, ss) => s + ss.newCount, 0);
44+
return NextResponse.json({ searches, total: searches.length, withAlerts, totalNew });
45+
}
46+
47+
export async function POST(req: Request) {
48+
try {
49+
const body = await req.json();
50+
if (body.toggleId) {
51+
const searches = load(); const ss = searches.find(s => s.id === body.toggleId);
52+
if (!ss) return NextResponse.json({ error: 'Non trovato' }, { status: 404 });
53+
ss.alertEnabled = !ss.alertEnabled; save(searches);
54+
return NextResponse.json({ ok: true, id: ss.id, alertEnabled: ss.alertEnabled });
55+
}
56+
const ss: SavedSearch = { id: `ss-${crypto.randomBytes(4).toString('hex')}`, name: body.name ?? '', query: body.query ?? '', filters: body.filters ?? {}, alertEnabled: true, frequency: body.frequency ?? 'daily', newCount: 0, lastRun: Date.now(), createdAt: Date.now() };
57+
if (!ss.name || !ss.query) return NextResponse.json({ error: 'name e query richiesti' }, { status: 400 });
58+
const searches = load(); searches.push(ss); save(searches);
59+
return NextResponse.json({ ok: true, search: ss });
60+
} catch (err) { return NextResponse.json({ error: String(err) }, { status: 500 }); }
61+
}
62+
63+
export async function DELETE(req: Request) {
64+
try {
65+
const { id } = await req.json() as { id: string };
66+
if (!id) return NextResponse.json({ error: 'id richiesto' }, { status: 400 });
67+
const searches = load(); const idx = searches.findIndex(s => s.id === id);
68+
if (idx === -1) return NextResponse.json({ error: 'Non trovato' }, { status: 404 });
69+
searches.splice(idx, 1); save(searches);
70+
return NextResponse.json({ ok: true });
71+
} catch (err) { return NextResponse.json({ error: String(err) }, { status: 500 }); }
72+
}

web/app/saved-searches/page.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useEffect, useState, useCallback } from 'react'
5+
6+
type SavedSearch = { id: string; name: string; query: string; filters: Record<string, string>; alertEnabled: boolean; frequency: string; newCount: number; lastRun: number; createdAt: number }
7+
8+
const FREQ_LABEL: Record<string, string> = { realtime: 'tempo reale', daily: 'giornaliero', weekly: 'settimanale' }
9+
10+
function timeAgo(ts: number): string {
11+
const m = Math.floor((Date.now() - ts) / 60000);
12+
if (m < 60) return `${m}m fa`; if (m < 1440) return `${Math.floor(m / 60)}h fa`; return `${Math.floor(m / 1440)}g fa`;
13+
}
14+
15+
function SearchRow({ ss, onToggle, onDelete }: { ss: SavedSearch; onToggle: (id: string) => void; onDelete: (id: string) => void }) {
16+
const filters = Object.entries(ss.filters).map(([k, v]) => `${k}:${v}`).join(' · ');
17+
return (
18+
<div className="flex items-center gap-3 px-5 py-3 border-b border-[var(--color-border)] hover:bg-[var(--color-row)] transition-colors">
19+
<div className="flex-1 min-w-0">
20+
<div className="flex items-center gap-2">
21+
<p className="text-[11px] text-[var(--color-bright)] font-medium truncate">{ss.name}</p>
22+
{ss.newCount > 0 && <span className="text-[8px] font-bold px-1.5 py-0.5 rounded-full" style={{ background: 'var(--color-green)', color: '#000' }}>{ss.newCount} nuovi</span>}
23+
</div>
24+
<p className="text-[9px] text-[var(--color-dim)] font-mono truncate">"{ss.query}" {filters && ${filters}`}</p>
25+
</div>
26+
<span className="text-[9px] text-[var(--color-dim)]">{FREQ_LABEL[ss.frequency] ?? ss.frequency}</span>
27+
<span className="text-[9px] text-[var(--color-dim)] w-14 text-right">{timeAgo(ss.lastRun)}</span>
28+
<button onClick={() => onToggle(ss.id)} className="text-[9px] font-bold cursor-pointer w-8"
29+
style={{ color: ss.alertEnabled ? 'var(--color-green)' : 'var(--color-dim)' }}>{ss.alertEnabled ? 'ON' : 'OFF'}</button>
30+
<button onClick={() => onDelete(ss.id)} className="text-[9px] font-bold cursor-pointer" style={{ color: 'var(--color-red)' }}>×</button>
31+
</div>
32+
)
33+
}
34+
35+
export default function SavedSearchesPage() {
36+
const [searches, setSearches] = useState<SavedSearch[]>([])
37+
const [total, setTotal] = useState(0)
38+
const [totalNew, setTotalNew] = useState(0)
39+
const [adding, setAdding] = useState(false)
40+
const [newName, setNewName] = useState('')
41+
const [newQuery, setNewQuery] = useState('')
42+
43+
const fetchData = useCallback(async () => {
44+
const res = await fetch('/api/saved-searches').catch(() => null);
45+
if (!res?.ok) return;
46+
const data = await res.json();
47+
setSearches(data.searches ?? []); setTotal(data.total ?? 0); setTotalNew(data.totalNew ?? 0);
48+
}, [])
49+
50+
useEffect(() => { fetchData() }, [fetchData])
51+
52+
const toggle = async (id: string) => {
53+
await fetch('/api/saved-searches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toggleId: id }) }).catch(() => null);
54+
fetchData();
55+
}
56+
57+
const remove = async (id: string) => {
58+
await fetch('/api/saved-searches', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }).catch(() => null);
59+
fetchData();
60+
}
61+
62+
const add = async () => {
63+
if (!newName.trim() || !newQuery.trim()) return;
64+
await fetch('/api/saved-searches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, query: newQuery }) }).catch(() => null);
65+
setNewName(''); setNewQuery(''); setAdding(false); fetchData();
66+
}
67+
68+
const inputStyle = { background: 'var(--color-deep)', color: 'var(--color-muted)', border: '1px solid var(--color-border)' } as const;
69+
70+
return (
71+
<div style={{ animation: 'fade-in 0.35s ease both' }}>
72+
<div className="mb-8 pb-6 border-b border-[var(--color-border)]">
73+
<div className="flex items-center gap-2 mb-1">
74+
<Link href="/dashboard" className="text-[10px] text-[var(--color-dim)] hover:text-[var(--color-muted)] no-underline transition-colors">Dashboard</Link>
75+
<span className="text-[var(--color-border)]">/</span>
76+
<span className="text-[10px] text-[var(--color-muted)]">Ricerche Salvate</span>
77+
</div>
78+
<div className="flex items-center justify-between mt-3">
79+
<div><h1 className="text-2xl font-bold tracking-tight text-[var(--color-white)]">Ricerche Salvate</h1>
80+
<p className="text-[var(--color-muted)] text-[11px] mt-1">{total} ricerche · {totalNew} nuovi risultati</p></div>
81+
<button onClick={() => setAdding(!adding)} className="px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer" style={{ background: 'var(--color-green)', color: '#000' }}>{adding ? 'Annulla' : '+ Nuova'}</button>
82+
</div>
83+
</div>
84+
85+
{adding && (
86+
<div className="mb-4 p-4 rounded-lg flex gap-2 items-end" style={{ background: 'var(--color-row)', border: '1px solid var(--color-border)' }}>
87+
<div className="flex flex-col gap-0.5"><label className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">NOME</label><input value={newName} onChange={e => setNewName(e.target.value)} className="text-[10px] px-2 py-1.5 rounded w-40" style={inputStyle} /></div>
88+
<div className="flex flex-col gap-0.5 flex-1"><label className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">QUERY</label><input value={newQuery} onChange={e => setNewQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && add()} className="text-[10px] px-2 py-1.5 rounded" style={inputStyle} /></div>
89+
<button onClick={add} className="px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer" style={{ background: 'var(--color-green)', color: '#000' }}>Salva</button>
90+
</div>
91+
)}
92+
93+
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden bg-[var(--color-panel)]">
94+
<div className="flex items-center gap-3 px-5 py-2 border-b border-[var(--color-border)]" style={{ background: 'var(--color-deep)' }}>
95+
<span className="flex-1 text-[8px] font-bold tracking-widest text-[var(--color-dim)]">RICERCA</span>
96+
<span className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">FREQ.</span>
97+
<span className="w-14 text-[8px] font-bold tracking-widest text-[var(--color-dim)] text-right">ULTIMO</span>
98+
<span className="w-8 text-[8px] font-bold tracking-widest text-[var(--color-dim)] text-center">ALERT</span>
99+
<span className="w-4" />
100+
</div>
101+
{searches.length === 0
102+
? <div className="py-12 text-center"><p className="text-[var(--color-dim)] text-[12px]">Nessuna ricerca salvata.</p></div>
103+
: searches.map(ss => <SearchRow key={ss.id} ss={ss} onToggle={toggle} onDelete={remove} />)}
104+
</div>
105+
</div>
106+
)
107+
}

0 commit comments

Comments
 (0)