diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index dc97ce8..f6b1ea8 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -21,6 +21,8 @@ function getStats() { const groups = (db.prepare("SELECT COUNT(*) AS n FROM domain_groups").get() as { n: number }).n; const hits24h = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since24h) as { n: number }).n; const hits30d = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since30d) as { n: number }).n; + const visitors24h = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE ts > ?").get(since24h) as { n: number }).n; + const visitors30d = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE ts > ?").get(since30d) as { n: number }).n; const dailyRows = db.prepare(` SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits @@ -36,7 +38,7 @@ function getStats() { ORDER BY hits DESC LIMIT 10 `).all(since30d) as { domain: string; hits: number }[]; - return { totalDomains, activeDomains, pendingDomains, groups, hits24h, hits30d, dailyRows, topRows }; + return { totalDomains, activeDomains, pendingDomains, groups, hits24h, hits30d, visitors24h, visitors30d, dailyRows, topRows }; } export default function DashboardPage() { @@ -58,7 +60,7 @@ export default function DashboardPage() {
} label="Domains" value={s.totalDomains} sub={`${s.activeDomains} aktiv`} /> } label="Wartend" value={s.pendingDomains} sub="DNS-Verify nötig" /> - } label="Hits (24h)" value={s.hits24h} sub={`${s.hits30d} in 30 Tagen`} /> + } label="Hits (24h)" value={s.hits24h} sub={`${s.visitors24h} Besucher`} /> } label="Gruppen" value={s.groups} />
diff --git a/app/(app)/domains/BulkSunsetClient.tsx b/app/(app)/domains/BulkSunsetClient.tsx new file mode 100644 index 0000000..bbdd446 --- /dev/null +++ b/app/(app)/domains/BulkSunsetClient.tsx @@ -0,0 +1,215 @@ +"use client"; +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { CheckCircle2, Clock, AlertCircle, ArrowRight, Loader2, Sunset } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +export type DomainListRow = { + id: number; + domain: string; + status: "pending" | "active" | "error"; + target_url: string | null; + group_id: number | null; + group_name: string | null; + group_target: string | null; + total_hits: number; + last_hit: number | null; + sunset_config: string | null; +}; + +function timeAgo(ts: number | null): string { + if (!ts) return "—"; + const diff = Date.now() - ts; + const m = Math.floor(diff / 60000); + if (m < 1) return "gerade eben"; + if (m < 60) return `vor ${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `vor ${h} h`; + const d = Math.floor(h / 24); + return `vor ${d} d`; +} + +export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { + const router = useRouter(); + const [selected, setSelected] = useState>(new Set()); + const [bulkOpen, setBulkOpen] = useState(false); + const [enabled, setEnabled] = useState(true); + const [title, setTitle] = useState("Diese Domain wird abgeschaltet"); + const [message, setMessage] = useState(""); + const [buttonLabel, setButtonLabel] = useState("Weiter"); + const [sunsetDate, setSunsetDate] = useState(""); + const [saving, setSaving] = useState(false); + + function toggle(id: number) { + setSelected((cur) => { + const next = new Set(cur); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + function toggleAll() { + if (selected.size === domains.length) setSelected(new Set()); + else setSelected(new Set(domains.map((d) => d.id))); + } + + async function applyBulk() { + setSaving(true); + try { + const cfg = enabled + ? { enabled: true, title, message, button_label: buttonLabel, sunset_date: sunsetDate || undefined } + : null; + const r = await fetch("/api/domains/sunset-bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domain_ids: Array.from(selected), config: cfg }), + }); + if (r.ok) { + setBulkOpen(false); + setSelected(new Set()); + router.refresh(); + } + } finally { + setSaving(false); + } + } + + return ( +
+ {selected.size > 0 && ( +
+ {selected.size} ausgewählt +
+ + +
+
+ )} + + {domains.length === 0 ? ( + + +

Noch keine Domain angelegt.

+ +
+
+ ) : ( + + + + + + + + + + + + + + + + + {domains.map((d) => { + const sunsetEnabled = (() => { + try { return d.sunset_config ? JSON.parse(d.sunset_config).enabled === true : false; } catch { return false; } + })(); + return ( + + + + + + + + + + + ); + })} + +
+ 0} onChange={toggleAll} /> + DomainStatusZielHitsLetzter HitSunset
+ toggle(d.id)} /> + {d.domain} + + + {d.target_url || d.group_target || "—"} + {d.group_name && ({d.group_name})} + {d.total_hits.toLocaleString("de-DE")}{timeAgo(d.last_hit)} + {sunsetEnabled ? aktiv : } + + +
+
+
+ )} + + + + + Sunset-Hinweis für {selected.size} Domain{selected.size === 1 ? "" : "s"} + Setzt eine Hinweisseite vor dem Redirect. Nutzer klickt sich aktiv durch. + +
+ + {enabled && ( + <> +
+ + setTitle(e.target.value)} /> +
+
+ +