feat: dashboard UI with product cards + sparklines
This commit is contained in:
@@ -1,3 +1,63 @@
|
|||||||
export default function Home() {
|
import Link from 'next/link'
|
||||||
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
|
import { desc, eq, sql } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots } from '@/lib/db'
|
||||||
|
import { ProductCard } from '@/components/ProductCard'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const rows = await db.execute<{
|
||||||
|
id: string; url: string; shop: string; name: string; image_url: string | null;
|
||||||
|
last_price: string | null; min_price: string | null;
|
||||||
|
}>(sql`
|
||||||
|
select p.id, p.url, p.shop, p.name, p.image_url,
|
||||||
|
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
|
||||||
|
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
|
||||||
|
from products p where p.enabled = true order by p.created_at desc
|
||||||
|
`)
|
||||||
|
|
||||||
|
const sparklines = new Map<string, Array<{ price: number; t: string }>>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const snaps = await db.select({ price: priceSnapshots.price, scrapedAt: priceSnapshots.scrapedAt })
|
||||||
|
.from(priceSnapshots)
|
||||||
|
.where(eq(priceSnapshots.productId, r.id))
|
||||||
|
.orderBy(desc(priceSnapshots.scrapedAt))
|
||||||
|
.limit(30)
|
||||||
|
sparklines.set(r.id, snaps
|
||||||
|
.filter((s) => s.price !== null)
|
||||||
|
.reverse()
|
||||||
|
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl p-6">
|
||||||
|
<header className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Preis-Tracker</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href="/add" className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500">+ Produkt</Link>
|
||||||
|
<Link href="/api/auth/logout" className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Logout</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded border border-dashed border-zinc-700 p-12 text-center text-zinc-400">
|
||||||
|
Noch keine Produkte. <Link href="/add" className="text-emerald-400 underline">Erstes hinzufügen</Link>.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<ProductCard
|
||||||
|
key={r.id}
|
||||||
|
id={r.id}
|
||||||
|
name={r.name}
|
||||||
|
shop={r.shop}
|
||||||
|
imageUrl={r.image_url}
|
||||||
|
lastPrice={r.last_price}
|
||||||
|
minPrice={r.min_price}
|
||||||
|
sparkline={sparklines.get(r.id) ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/components/ProductCard.tsx
Normal file
37
src/components/ProductCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Sparkline } from './Sparkline'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
shop: string
|
||||||
|
imageUrl: string | null
|
||||||
|
lastPrice: string | null
|
||||||
|
minPrice: string | null
|
||||||
|
sparkline: Array<{ price: number; t: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCard(p: Props) {
|
||||||
|
const last = p.lastPrice ? Number(p.lastPrice) : null
|
||||||
|
const min = p.minPrice ? Number(p.minPrice) : null
|
||||||
|
const deltaFromMin = last !== null && min !== null ? (last - min).toFixed(2) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/products/${p.id}`} className="block rounded-lg border border-zinc-800 bg-zinc-900 p-4 hover:border-zinc-700 transition">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{p.imageUrl && <img src={p.imageUrl} alt="" className="h-16 w-16 object-contain rounded bg-white" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-zinc-500">{p.shop}</div>
|
||||||
|
<div className="truncate text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-lg font-semibold">{last !== null ? `${last.toFixed(2)} €` : '—'}</span>
|
||||||
|
{deltaFromMin !== null && (
|
||||||
|
<span className="text-xs text-zinc-400">+{deltaFromMin} € vom Tief</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2"><Sparkline data={p.sparkline} /></div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/components/Sparkline.tsx
Normal file
14
src/components/Sparkline.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use client'
|
||||||
|
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
export function Sparkline({ data }: { data: Array<{ price: number; t: string }> }) {
|
||||||
|
if (data.length === 0) return <div className="h-10 text-xs text-zinc-500">keine Daten</div>
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={40}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<YAxis hide domain={['dataMin', 'dataMax']} />
|
||||||
|
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user