feat: dashboard UI with product cards + sparklines
This commit is contained in:
@@ -1,3 +1,63 @@
|
||||
export default function Home() {
|
||||
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
|
||||
import Link from 'next/link'
|
||||
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