feat: optimize drops to 24h only, award-winning analyze panel
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

This commit is contained in:
2025-12-15 22:46:29 +01:00
parent 90256e6049
commit 7a9d7703ca
8 changed files with 639 additions and 653 deletions

View File

@ -55,7 +55,7 @@ async def api_get_zone_stats(
@router.get("") @router.get("")
async def api_get_drops( async def api_get_drops(
tld: Optional[str] = Query(None, description="Filter by TLD"), tld: Optional[str] = Query(None, description="Filter by TLD"),
days: int = Query(7, ge=1, le=30, description="Days to look back"), hours: int = Query(24, ge=1, le=48, description="Hours to look back (max 48h, we only store 48h)"),
min_length: Optional[int] = Query(None, ge=1, le=63, description="Minimum domain length"), min_length: Optional[int] = Query(None, ge=1, le=63, description="Minimum domain length"),
max_length: Optional[int] = Query(None, ge=1, le=63, description="Maximum domain length"), max_length: Optional[int] = Query(None, ge=1, le=63, description="Maximum domain length"),
exclude_numeric: bool = Query(False, description="Exclude numeric-only domains"), exclude_numeric: bool = Query(False, description="Exclude numeric-only domains"),
@ -86,7 +86,7 @@ async def api_get_drops(
result = await get_dropped_domains( result = await get_dropped_domains(
db=db, db=db,
tld=tld, tld=tld,
days=days, hours=hours,
min_length=min_length, min_length=min_length,
max_length=max_length, max_length=max_length,
exclude_numeric=exclude_numeric, exclude_numeric=exclude_numeric,

View File

@ -675,6 +675,15 @@ def setup_scheduler():
replace_existing=True, replace_existing=True,
) )
# Zone data cleanup (hourly - delete drops older than 48h)
scheduler.add_job(
cleanup_zone_data,
CronTrigger(minute=45), # Every hour at :45
id="zone_cleanup",
name="Zone Data Cleanup (hourly)",
replace_existing=True,
)
logger.info( logger.info(
f"Scheduler configured:" f"Scheduler configured:"
f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)" f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)"
@ -850,6 +859,26 @@ async def scrape_auctions():
logger.exception(f"Auction scrape failed: {e}") logger.exception(f"Auction scrape failed: {e}")
async def cleanup_zone_data():
"""Clean up old zone file data to save storage."""
logger.info("Starting zone data cleanup...")
try:
from app.services.zone_file import cleanup_old_drops, cleanup_old_snapshots
async with AsyncSessionLocal() as db:
# Delete drops older than 48h
drops_deleted = await cleanup_old_drops(db, hours=48)
# Delete snapshots older than 7 days
snapshots_deleted = await cleanup_old_snapshots(db, keep_days=7)
logger.info(f"Zone cleanup: {drops_deleted} drops, {snapshots_deleted} snapshots deleted")
except Exception as e:
logger.exception(f"Zone data cleanup failed: {e}")
async def sync_zone_files(): async def sync_zone_files():
"""Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs).""" """Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs)."""
logger.info("Starting zone file sync...") logger.info("Starting zone file sync...")

View File

@ -256,7 +256,7 @@ class ZoneFileService:
async def get_dropped_domains( async def get_dropped_domains(
db: AsyncSession, db: AsyncSession,
tld: Optional[str] = None, tld: Optional[str] = None,
days: int = 7, hours: int = 24,
min_length: Optional[int] = None, min_length: Optional[int] = None,
max_length: Optional[int] = None, max_length: Optional[int] = None,
exclude_numeric: bool = False, exclude_numeric: bool = False,
@ -267,8 +267,9 @@ async def get_dropped_domains(
) -> dict: ) -> dict:
""" """
Get recently dropped domains with filters. Get recently dropped domains with filters.
Only returns drops from last 24-48h (we don't store older data).
""" """
cutoff = datetime.utcnow() - timedelta(days=days) cutoff = datetime.utcnow() - timedelta(hours=hours)
query = select(DroppedDomain).where(DroppedDomain.dropped_date >= cutoff) query = select(DroppedDomain).where(DroppedDomain.dropped_date >= cutoff)
count_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff) count_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff)
@ -336,13 +337,12 @@ async def get_zone_stats(db: AsyncSession) -> dict:
ch_snapshot = ch_result.scalar_one_or_none() ch_snapshot = ch_result.scalar_one_or_none()
li_snapshot = li_result.scalar_one_or_none() li_snapshot = li_result.scalar_one_or_none()
# Count recent drops # Count drops from last 24h only
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) cutoff_24h = datetime.utcnow() - timedelta(hours=24)
week_ago = today - timedelta(days=7)
drops_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= week_ago) drops_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff_24h)
drops_result = await db.execute(drops_query) drops_result = await db.execute(drops_query)
weekly_drops = drops_result.scalar() or 0 daily_drops = drops_result.scalar() or 0
return { return {
"ch": { "ch": {
@ -353,5 +353,48 @@ async def get_zone_stats(db: AsyncSession) -> dict:
"domain_count": li_snapshot.domain_count if li_snapshot else 0, "domain_count": li_snapshot.domain_count if li_snapshot else 0,
"last_sync": li_snapshot.snapshot_date.isoformat() if li_snapshot else None "last_sync": li_snapshot.snapshot_date.isoformat() if li_snapshot else None
}, },
"weekly_drops": weekly_drops "daily_drops": daily_drops
} }
async def cleanup_old_drops(db: AsyncSession, hours: int = 48) -> int:
"""
Delete dropped domains older than specified hours.
Default: Keep only last 48h for safety margin (24h display + 24h buffer).
Returns number of deleted records.
"""
from sqlalchemy import delete
cutoff = datetime.utcnow() - timedelta(hours=hours)
# Delete old drops
stmt = delete(DroppedDomain).where(DroppedDomain.dropped_date < cutoff)
result = await db.execute(stmt)
await db.commit()
deleted = result.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} old dropped domains (older than {hours}h)")
return deleted
async def cleanup_old_snapshots(db: AsyncSession, keep_days: int = 7) -> int:
"""
Delete zone snapshots older than specified days.
Keep at least 7 days of metadata for debugging.
Returns number of deleted records.
"""
from sqlalchemy import delete
cutoff = datetime.utcnow() - timedelta(days=keep_days)
stmt = delete(ZoneSnapshot).where(ZoneSnapshot.snapshot_date < cutoff)
result = await db.execute(stmt)
await db.commit()
deleted = result.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} old zone snapshots (older than {keep_days}d)")
return deleted

View File

@ -2,23 +2,73 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { X, RefreshCw, Search, Shield, Zap, Copy, ExternalLink } from 'lucide-react' import {
X,
RefreshCw,
Copy,
ExternalLink,
Shield,
TrendingUp,
AlertTriangle,
DollarSign,
Check,
Zap,
Globe,
Calendar,
Link2,
Radio,
Eye,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types' import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
function statusPill(status: string) { // ============================================================================
// HELPERS
// ============================================================================
function getStatusColor(status: string) {
switch (status) { switch (status) {
case 'pass': case 'pass':
return 'bg-accent/10 text-accent border-accent/20' return { bg: 'bg-accent/10', text: 'text-accent', border: 'border-accent/30', icon: Check }
case 'warn': case 'warn':
return 'bg-amber-400/10 text-amber-300 border-amber-400/20' return { bg: 'bg-amber-400/10', text: 'text-amber-300', border: 'border-amber-400/30', icon: AlertTriangle }
case 'fail': case 'fail':
return 'bg-red-500/10 text-red-300 border-red-500/20' return { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30', icon: X }
case 'na':
return 'bg-white/5 text-white/30 border-white/10'
default: default:
return 'bg-white/5 text-white/50 border-white/10' return { bg: 'bg-white/5', text: 'text-white/40', border: 'border-white/10', icon: null }
}
}
function getSectionIcon(key: string) {
switch (key) {
case 'authority':
return Shield
case 'market':
return TrendingUp
case 'risk':
return AlertTriangle
case 'value':
return DollarSign
default:
return Globe
}
}
function getSectionColor(key: string) {
switch (key) {
case 'authority':
return 'text-blue-400'
case 'market':
return 'text-emerald-400'
case 'risk':
return 'text-amber-400'
case 'value':
return 'text-violet-400'
default:
return 'text-white/60'
} }
} }
@ -32,7 +82,7 @@ async function copyToClipboard(text: string) {
} }
function formatValue(value: unknown): string { function formatValue(value: unknown): string {
if (value === null || value === undefined) return 'N/A' if (value === null || value === undefined) return ''
if (typeof value === 'string') return value if (typeof value === 'string') return value
if (typeof value === 'number') return String(value) if (typeof value === 'number') return String(value)
if (typeof value === 'boolean') return value ? 'Yes' : 'No' if (typeof value === 'boolean') return value ? 'Yes' : 'No'
@ -40,28 +90,35 @@ function formatValue(value: unknown): string {
return 'Details' return 'Details'
} }
function filterSection(section: AnalyzeSection, filterText: string): AnalyzeSection {
const f = filterText.trim().toLowerCase()
if (!f) return section
const items = section.items.filter((it) => {
const base = `${it.label} ${it.key} ${formatValue(it.value)}`.toLowerCase()
return base.includes(f)
})
return { ...section, items }
}
function isMatrix(item: AnalyzeItem) { function isMatrix(item: AnalyzeItem) {
return item.key === 'tld_matrix' && Array.isArray(item.value) return item.key === 'tld_matrix' && Array.isArray(item.value)
} }
// ============================================================================
// COMPONENT
// ============================================================================
export function AnalyzePanel() { export function AnalyzePanel() {
const { isOpen, domain, close, fastMode, setFastMode, filterText, setFilterText, sectionVisibility, setSectionVisibility } = const {
useAnalyzePanelStore() isOpen,
domain,
close,
fastMode,
setFastMode,
sectionVisibility,
setSectionVisibility
} = useAnalyzePanelStore()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<AnalyzeResponse | null>(null) const [data, setData] = useState<AnalyzeResponse | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
authority: true,
market: true,
risk: true,
value: true
})
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!domain) return if (!domain) return
@ -97,9 +154,7 @@ export function AnalyzePanel() {
} }
} }
run() run()
return () => { return () => { cancelled = true }
cancelled = true
}
}, [isOpen, domain, fastMode]) }, [isOpen, domain, fastMode])
// ESC to close // ESC to close
@ -112,210 +167,296 @@ export function AnalyzePanel() {
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [isOpen, close]) }, [isOpen, close])
const toggleSection = useCallback((key: string) => {
setExpandedSections(prev => ({ ...prev, [key]: !prev[key] }))
}, [])
const visibleSections = useMemo(() => { const visibleSections = useMemo(() => {
const sections = data?.sections || [] const sections = data?.sections || []
const order = ['authority', 'market', 'risk', 'value'] const order = ['authority', 'market', 'risk', 'value']
const ordered = [...sections].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) return [...sections]
return ordered .sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
.filter((s) => sectionVisibility[s.key] !== false) .filter((s) => sectionVisibility[s.key] !== false)
.map((s) => filterSection(s, filterText)) }, [data, sectionVisibility])
.filter((s) => s.items.length > 0 || !filterText.trim())
}, [data, sectionVisibility, filterText]) // Calculate overall score
const overallScore = useMemo(() => {
if (!data?.sections) return null
let pass = 0, warn = 0, fail = 0
data.sections.forEach(s => {
s.items.forEach(item => {
if (item.status === 'pass') pass++
else if (item.status === 'warn') warn++
else if (item.status === 'fail') fail++
})
})
const total = pass + warn + fail
if (total === 0) return null
const score = Math.round((pass * 100 + warn * 50) / total)
return { score, pass, warn, fail, total }
}, [data])
const headerDomain = data?.domain || domain || '' const headerDomain = data?.domain || domain || ''
const computedAt = data?.computed_at ? new Date(data.computed_at).toLocaleString() : null
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-[200]"> <div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-black/80" onClick={close} /> {/* Backdrop */}
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={close} />
{/* Panel */}
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[480px] bg-[#050505] border-l border-white/[0.06] flex flex-col overflow-hidden">
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[520px] bg-[#0A0A0A] border-l border-white/[0.08] flex flex-col">
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-white/[0.08] flex items-start gap-3"> <div className="shrink-0 border-b border-white/[0.06]">
<div className="flex-1 min-w-0"> {/* Top Bar */}
<div className="flex items-center gap-2"> <div className="px-4 py-3 flex items-center justify-between">
<Shield className="w-4 h-4 text-accent" /> <div className="flex items-center gap-3">
<div className="text-sm font-bold text-white font-mono truncate">ANALYZE</div> <div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
{data?.cached ? ( <Shield className="w-5 h-5 text-accent" />
<span className="text-[9px] font-mono px-1.5 py-0.5 border border-white/10 text-white/40">CACHED</span> </div>
) : null} <div>
<div className="text-xs font-mono text-white/40 uppercase tracking-wider">Analyze</div>
<div className="text-base font-bold text-white font-mono truncate max-w-[200px]">
{headerDomain}
</div>
</div>
</div> </div>
<div className="mt-1 flex items-center gap-2">
<div className="text-[12px] text-white/70 font-mono truncate">{headerDomain}</div> <div className="flex items-center gap-1.5">
<button <button
onClick={async () => { onClick={async () => {
const ok = await copyToClipboard(headerDomain) const ok = await copyToClipboard(headerDomain)
setCopied(ok) setCopied(ok)
window.setTimeout(() => setCopied(false), 900) setTimeout(() => setCopied(false), 1500)
}} }}
className="p-1 border border-white/10 text-white/50 hover:text-white transition-colors" className={clsx(
title="Copy domain" "w-8 h-8 flex items-center justify-center border transition-all",
copied ? "border-accent bg-accent/10 text-accent" : "border-white/10 text-white/40 hover:text-white"
)}
> >
<Copy className="w-3.5 h-3.5" /> {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<a
href={`https://${encodeURIComponent(headerDomain)}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={refresh}
disabled={loading}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white transition-colors disabled:opacity-50"
>
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
</button>
<button
onClick={close}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button> </button>
{copied ? <span className="text-[10px] font-mono text-accent">Copied</span> : null}
</div> </div>
{computedAt ? <div className="text-[10px] text-white/30 font-mono mt-1">Computed: {computedAt}</div> : null}
</div> </div>
<div className="flex items-center gap-2"> {/* Score Bar */}
<a {overallScore && !loading && (
href={`https://${encodeURIComponent(headerDomain)}`} <div className="px-4 pb-3">
target="_blank" <div className="flex items-center gap-3">
rel="noopener noreferrer" <div className={clsx(
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors" "text-2xl font-bold font-mono",
title="Open in browser" overallScore.score >= 70 ? "text-accent" : overallScore.score >= 40 ? "text-amber-400" : "text-red-400"
> )}>
<ExternalLink className="w-4 h-4" /> {overallScore.score}
</a> </div>
<button <div className="flex-1">
onClick={refresh} <div className="h-1.5 bg-white/5 rounded-full overflow-hidden flex">
disabled={loading} <div
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors disabled:opacity-50" className="h-full bg-accent transition-all"
title="Refresh" style={{ width: `${(overallScore.pass / overallScore.total) * 100}%` }}
> />
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} /> <div
</button> className="h-full bg-amber-400 transition-all"
<button onClick={close} className="p-2 border border-white/10 text-white/60 hover:text-white transition-colors"> style={{ width: `${(overallScore.warn / overallScore.total) * 100}%` }}
<X className="w-4 h-4" /> />
</button> <div
</div> className="h-full bg-red-500 transition-all"
</div> style={{ width: `${(overallScore.fail / overallScore.total) * 100}%` }}
/>
{/* Controls */} </div>
<div className="px-4 py-3 border-b border-white/[0.08]"> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-[10px] font-mono">
<div className="relative flex-1"> <span className="text-accent">{overallScore.pass} pass</span>
<Search className="w-4 h-4 text-white/25 absolute left-3 top-1/2 -translate-y-1/2" /> <span className="text-amber-400">{overallScore.warn} warn</span>
<input <span className="text-red-400">{overallScore.fail} fail</span>
value={filterText} </div>
onChange={(e) => setFilterText(e.target.value)} </div>
placeholder="Filter signals…"
className="w-full bg-white/[0.02] border border-white/10 pl-9 pr-3 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono"
/>
</div> </div>
)}
{/* Mode Toggle */}
<div className="px-4 pb-3 flex items-center gap-2">
<button <button
onClick={() => setFastMode(!fastMode)} onClick={() => setFastMode(!fastMode)}
className={clsx( className={clsx(
'px-3 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center gap-1.5 transition-all font-mono', "flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition-all",
fastMode ? 'border-accent/30 bg-accent/10 text-accent' : 'border-white/10 text-white/50 hover:text-white' fastMode
? "border-accent/30 bg-accent/10 text-accent"
: "border-white/10 text-white/40 hover:text-white"
)} )}
title="Fast mode skips slower HTTP/SSL checks"
> >
<Zap className="w-3.5 h-3.5" /> <Zap className="w-3 h-3" />
Fast Fast
</button> </button>
</div> {data?.cached && (
<span className="text-[10px] font-mono text-white/30 px-2 py-1 border border-white/10">
<div className="mt-2 flex flex-wrap gap-2"> Cached
{(['authority', 'market', 'risk', 'value'] as const).map((key) => { </span>
const on = sectionVisibility[key] !== false )}
return (
<button
key={key}
onClick={() => setSectionVisibility({ ...sectionVisibility, [key]: !on })}
className={clsx(
'px-2 py-1 text-[10px] font-mono border transition-colors',
on ? 'border-white/10 text-white/50 hover:text-white' : 'border-white/10 text-white/25 bg-white/[0.02]'
)}
>
{key}
</button>
)
})}
</div> </div>
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loading ? ( {loading ? (
<div className="p-6 text-white/40 font-mono text-sm">Loading</div> <div className="flex items-center justify-center py-20">
) : error ? ( <div className="text-center">
<div className="p-6"> <RefreshCw className="w-6 h-6 text-accent animate-spin mx-auto mb-3" />
<div className="text-sm font-mono text-red-300 mb-2">Analyze failed</div> <div className="text-sm font-mono text-white/40">Analyzing...</div>
<div className="text-[12px] text-white/40 font-mono break-words">{error}</div>
</div>
) : !data ? (
<div className="p-6 text-white/40 font-mono text-sm">No data.</div>
) : (
<div className="p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{visibleSections.map((section) => (
<div key={section.key} className="border border-white/[0.08] bg-[#020202]">
<div className="px-3 py-2 border-b border-white/[0.08] flex items-center justify-between">
<div className="text-[10px] font-bold uppercase tracking-wider text-white/60">{section.title}</div>
<div className="text-[10px] font-mono text-white/25">{section.key}</div>
</div>
<div className="divide-y divide-white/[0.06]">
{section.items.map((it) => (
<div key={it.key} className="px-3 py-2 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-[11px] font-mono text-white/70">{it.label}</div>
<div className="text-[12px] font-mono text-white/40 mt-1 break-words">
{isMatrix(it) ? (
<div className="grid grid-cols-2 gap-1">
{(it.value as any[]).slice(0, 14).map((row: any) => (
<div
key={String(row.domain)}
className="flex items-center justify-between border border-white/10 bg-white/[0.02] px-2 py-1"
>
<span className="text-white/50">{String(row.domain)}</span>
<span
className={clsx(
'text-[10px] font-bold',
row.status === 'available'
? 'text-accent'
: row.status === 'taken'
? 'text-white/40'
: 'text-amber-300'
)}
>
{String(row.status).toUpperCase()}
</span>
</div>
))}
</div>
) : (
formatValue(it.value)
)}
</div>
{it.details && Object.keys(it.details).length ? (
<details className="mt-2">
<summary className="text-[10px] font-mono text-white/30 cursor-pointer hover:text-white/50">
Details
</summary>
<pre className="mt-2 text-[10px] leading-relaxed font-mono text-white/30 overflow-x-auto bg-black/30 border border-white/10 p-2">
{JSON.stringify(it.details, null, 2)}
</pre>
</details>
) : null}
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className={clsx('text-[9px] font-bold uppercase tracking-wider px-2 py-1 border', statusPill(it.status))}>
{it.status}
</span>
<span className="text-[9px] font-mono text-white/25">{it.source}</span>
</div>
</div>
))}
</div>
</div>
))}
</div> </div>
</div> </div>
)} ) : error ? (
</div> <div className="p-4">
<div className="border border-red-500/20 bg-red-500/5 p-4">
<div className="text-sm font-bold text-red-400 mb-1">Analysis Failed</div>
<div className="text-xs font-mono text-white/40">{error}</div>
</div>
</div>
) : !data ? (
<div className="flex items-center justify-center py-20">
<div className="text-sm font-mono text-white/30">No data</div>
</div>
) : (
<div className="p-3 space-y-2">
{visibleSections.map((section) => {
const SectionIcon = getSectionIcon(section.key)
const sectionColor = getSectionColor(section.key)
const isExpanded = expandedSections[section.key] !== false
{/* Footer */} return (
<div className="px-4 py-3 border-t border-white/[0.08] bg-white/[0.02]"> <div key={section.key} className="border border-white/[0.06] bg-[#020202] overflow-hidden">
<div className="text-[10px] font-mono text-white/30"> {/* Section Header */}
Open-data-first. Some signals (trademarks/search volume/Wayback) require explicit data sources; well only add them when we can do it without external APIs. <button
</div> onClick={() => toggleSection(section.key)}
className="w-full px-3 py-2.5 flex items-center justify-between hover:bg-white/[0.02] transition-colors"
>
<div className="flex items-center gap-2">
<SectionIcon className={clsx("w-4 h-4", sectionColor)} />
<span className="text-xs font-bold uppercase tracking-wider text-white/80">
{section.title}
</span>
<span className="text-[10px] font-mono text-white/30">
{section.items.length}
</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-white/30" />
) : (
<ChevronDown className="w-4 h-4 text-white/30" />
)}
</button>
{/* Section Items */}
{isExpanded && (
<div className="border-t border-white/[0.04]">
{section.items.map((item) => {
const statusStyle = getStatusColor(item.status)
const StatusIcon = statusStyle.icon
return (
<div
key={item.key}
className="px-3 py-2 border-b border-white/[0.04] last:border-0 hover:bg-white/[0.01] transition-colors"
>
<div className="flex items-start gap-3">
{/* Status Indicator */}
<div className={clsx(
"w-6 h-6 flex items-center justify-center shrink-0 mt-0.5",
statusStyle.bg, statusStyle.border, "border"
)}>
{StatusIcon && <StatusIcon className={clsx("w-3 h-3", statusStyle.text)} />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-medium text-white/70">
{item.label}
</span>
<span className="text-[10px] font-mono text-white/30">
{item.source}
</span>
</div>
{/* Value */}
<div className="mt-1">
{isMatrix(item) ? (
<div className="grid grid-cols-3 gap-1">
{(item.value as any[]).slice(0, 12).map((row: any) => (
<div
key={String(row.domain)}
className={clsx(
"px-2 py-1 text-[10px] font-mono flex items-center justify-between border",
row.status === 'available'
? "border-accent/20 bg-accent/5 text-accent"
: "border-white/5 bg-white/[0.02] text-white/40"
)}
>
<span className="truncate">{String(row.domain)}</span>
{row.status === 'available' && <Check className="w-2.5 h-2.5 shrink-0" />}
</div>
))}
</div>
) : (
<div className={clsx(
"text-xs font-mono",
item.status === 'pass' ? "text-white/60" :
item.status === 'warn' ? "text-amber-300/80" :
item.status === 'fail' ? "text-red-300/80" : "text-white/40"
)}>
{formatValue(item.value)}
</div>
)}
</div>
{/* Details Toggle */}
{item.details && Object.keys(item.details).length > 0 && (
<details className="mt-2">
<summary className="text-[10px] font-mono text-white/25 cursor-pointer hover:text-white/40 select-none">
View details
</summary>
<pre className="mt-1.5 text-[9px] font-mono text-white/30 bg-black/40 border border-white/5 p-2 overflow-x-auto rounded">
{JSON.stringify(item.details, null, 2)}
</pre>
</details>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -350,55 +350,6 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
</div> </div>
)} )}
{/* Info Cards */}
{items.length === 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-accent/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
<Wand2 className="w-5 h-5 text-accent" />
</div>
<div>
<div className="text-sm font-bold text-white">AI-Powered</div>
<div className="text-[10px] font-mono text-white/40">Smart generation</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Generate pronounceable, memorable names following proven patterns like CVCVC.
</p>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<Zap className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Verified</div>
<div className="text-[10px] font-mono text-white/40">Real availability</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Every domain is checked via DNS/RDAP. Only verified available domains are shown.
</p>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<ExternalLink className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Instant Register</div>
<div className="text-[10px] font-mono text-white/40">One-click buy</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Found a perfect name? Register instantly via Namecheap with one click.
</p>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -5,12 +5,10 @@ import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { import {
Download,
Clock, Clock,
Globe, Globe,
Loader2, Loader2,
Search, Search,
Filter,
ChevronRight, ChevronRight,
ChevronLeft, ChevronLeft,
ChevronUp, ChevronUp,
@ -21,7 +19,7 @@ import {
Shield, Shield,
ExternalLink, ExternalLink,
Zap, Zap,
Calendar, Filter,
Ban, Ban,
Hash, Hash,
} from 'lucide-react' } from 'lucide-react'
@ -43,9 +41,23 @@ interface DroppedDomain {
interface ZoneStats { interface ZoneStats {
ch: { domain_count: number; last_sync: string | null } ch: { domain_count: number; last_sync: string | null }
li: { domain_count: number; last_sync: string | null } li: { domain_count: number; last_sync: string | null }
weekly_drops: number daily_drops: number
} }
// All supported TLDs
type SupportedTld = 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
const ALL_TLDS: { tld: SupportedTld; flag: string }[] = [
{ tld: 'ch', flag: '🇨🇭' },
{ tld: 'li', flag: '🇱🇮' },
{ tld: 'xyz', flag: '🌐' },
{ tld: 'org', flag: '🏛️' },
{ tld: 'online', flag: '💻' },
{ tld: 'info', flag: '' },
{ tld: 'dev', flag: '👨‍💻' },
{ tld: 'app', flag: '📱' },
]
// ============================================================================ // ============================================================================
// COMPONENT // COMPONENT
// ============================================================================ // ============================================================================
@ -65,14 +77,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
// All supported TLDs
type SupportedTld = 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
// Filter State // Filter State
const [selectedTld, setSelectedTld] = useState<SupportedTld | null>(null) const [selectedTld, setSelectedTld] = useState<SupportedTld | null>(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchFocused, setSearchFocused] = useState(false) const [searchFocused, setSearchFocused] = useState(false)
const [days, setDays] = useState(7)
const [minLength, setMinLength] = useState<number | undefined>(undefined) const [minLength, setMinLength] = useState<number | undefined>(undefined)
const [maxLength, setMaxLength] = useState<number | undefined>(undefined) const [maxLength, setMaxLength] = useState<number | undefined>(undefined)
const [excludeNumeric, setExcludeNumeric] = useState(true) const [excludeNumeric, setExcludeNumeric] = useState(true)
@ -84,8 +92,8 @@ export function DropsTab({ showToast }: DropsTabProps) {
const ITEMS_PER_PAGE = 50 const ITEMS_PER_PAGE = 50
// Sorting // Sorting
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('date') const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('length')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// Tracking // Tracking
const [tracking, setTracking] = useState<string | null>(null) const [tracking, setTracking] = useState<string | null>(null)
@ -100,7 +108,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
} }
}, []) }, [])
// Load Drops // Load Drops (only last 24h)
const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => { const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => {
if (isRefresh) setRefreshing(true) if (isRefresh) setRefreshing(true)
else setLoading(true) else setLoading(true)
@ -108,7 +116,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
try { try {
const result = await api.getDrops({ const result = await api.getDrops({
tld: selectedTld || undefined, tld: selectedTld || undefined,
days, hours: 24, // Only last 24h - fresh drops only!
min_length: minLength, min_length: minLength,
max_length: maxLength, max_length: maxLength,
exclude_numeric: excludeNumeric, exclude_numeric: excludeNumeric,
@ -121,7 +129,6 @@ export function DropsTab({ showToast }: DropsTabProps) {
setTotal(result.total) setTotal(result.total)
} catch (error: any) { } catch (error: any) {
console.error('Failed to load drops:', error) console.error('Failed to load drops:', error)
// If API returns error, show info message
if (error.message?.includes('401') || error.message?.includes('auth')) { if (error.message?.includes('401') || error.message?.includes('auth')) {
showToast('Login required to view drops', 'info') showToast('Login required to view drops', 'info')
} }
@ -131,7 +138,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
setLoading(false) setLoading(false)
setRefreshing(false) setRefreshing(false)
} }
}, [selectedTld, days, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast]) }, [selectedTld, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
// Initial Load // Initial Load
useEffect(() => { useEffect(() => {
@ -199,12 +206,15 @@ export function DropsTab({ showToast }: DropsTabProps) {
maxLength !== undefined, maxLength !== undefined,
excludeNumeric, excludeNumeric,
excludeHyphen, excludeHyphen,
days !== 7
].filter(Boolean).length ].filter(Boolean).length
const formatDate = (iso: string) => { const formatTime = (iso: string) => {
const d = new Date(iso) const d = new Date(iso)
return d.toLocaleDateString('de-CH', { day: '2-digit', month: 'short' }) const now = new Date()
const diffH = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60))
if (diffH < 1) return 'Just now'
if (diffH === 1) return '1h ago'
return `${diffH}h ago`
} }
if (loading && items.length === 0) { if (loading && items.length === 0) {
@ -217,286 +227,189 @@ export function DropsTab({ showToast }: DropsTabProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Stats Cards */} {/* Header Stats */}
{stats && ( <div className="flex items-center justify-between">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="flex items-center gap-3">
<div className="border border-accent/20 bg-accent/[0.03] p-3"> <div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
<div className="flex items-center gap-2 mb-1"> <Zap className="w-5 h-5 text-accent" />
<Zap className="w-4 h-4 text-accent" /> </div>
<span className="text-[10px] font-mono text-accent/60 uppercase">Weekly Drops</span> <div>
<div className="text-xl font-bold text-white font-mono">
{stats?.daily_drops?.toLocaleString() || total.toLocaleString()}
</div> </div>
<div className="text-xl font-bold text-accent font-mono"> <div className="text-[10px] font-mono text-white/40 uppercase">Fresh drops (24h)</div>
{stats.weekly_drops.toLocaleString()} </div>
</div> </div>
<div className="text-[10px] font-mono text-white/30"> <button
Last 7 days onClick={handleRefresh}
disabled={refreshing}
className="p-2 border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
</div>
{/* Search */}
<div className={clsx(
"relative border transition-all duration-200",
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<div className="flex items-center">
<Search className={clsx("w-4 h-4 ml-3 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="Search drops..."
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="p-3 text-white/30 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* TLD Quick Filter */}
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedTld(null)}
className={clsx(
"px-2.5 py-1.5 text-[10px] font-mono uppercase border transition-colors",
selectedTld === null ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
All
</button>
{ALL_TLDS.map(({ tld, flag }) => (
<button
key={tld}
onClick={() => setSelectedTld(tld)}
className={clsx(
"px-2.5 py-1.5 text-[10px] font-mono uppercase border transition-colors flex items-center gap-1",
selectedTld === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
<span className="text-xs">{flag}</span>.{tld}
</button>
))}
</div>
{/* Filter Toggle */}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
className={clsx(
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
filtersOpen ? "border-accent/30 bg-accent/[0.05]" : "border-white/[0.08] bg-white/[0.02]"
)}
>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Filters</span>
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>
)}
</div>
<ChevronRight className={clsx("w-4 h-4 text-white/30 transition-transform", filtersOpen && "rotate-90")} />
</button>
{/* Filters Panel */}
{filtersOpen && (
<div className="p-3 border border-white/[0.08] bg-white/[0.02] space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Length Filter */}
<div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Length</div>
<div className="flex gap-2 items-center">
<input
type="number"
value={minLength || ''}
onChange={(e) => setMinLength(e.target.value ? Number(e.target.value) : undefined)}
placeholder="Min"
className="w-16 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none font-mono"
min={1}
max={63}
/>
<span className="text-white/20"></span>
<input
type="number"
value={maxLength || ''}
onChange={(e) => setMaxLength(e.target.value ? Number(e.target.value) : undefined)}
placeholder="Max"
className="w-16 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none font-mono"
min={1}
max={63}
/>
</div> </div>
</div> </div>
<div className="border border-white/[0.08] bg-[#020202] p-3"> {/* Quality Filters */}
<div className="flex items-center gap-2 mb-1"> <div className="flex gap-2">
<span className="text-sm">🇨🇭🇱🇮</span> <button
<span className="text-[10px] font-mono text-white/40 uppercase">Switch.ch</span> onClick={() => setExcludeNumeric(!excludeNumeric)}
</div> className={clsx(
<div className="text-lg font-bold text-white font-mono"> "flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
{((stats.ch?.domain_count || 0) + (stats.li?.domain_count || 0)).toLocaleString()} excludeNumeric ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
</div> )}
<div className="text-[10px] font-mono text-white/30"> >
.ch + .li zones <div className="flex items-center gap-2">
</div> <Hash className="w-3.5 h-3.5 text-white/40" />
</div> <span className="text-[10px] font-mono text-white/60">No numbers</span>
</div>
<div className={clsx("w-3.5 h-3.5 border flex items-center justify-center", excludeNumeric ? "border-accent bg-accent" : "border-white/30")}>
{excludeNumeric && <span className="text-black text-[8px] font-bold"></span>}
</div>
</button>
<div className="border border-white/[0.08] bg-[#020202] p-3"> <button
<div className="flex items-center gap-2 mb-1"> onClick={() => setExcludeHyphen(!excludeHyphen)}
<Globe className="w-4 h-4 text-white/40" /> className={clsx(
<span className="text-[10px] font-mono text-white/40 uppercase">ICANN CZDS</span> "flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
</div> excludeHyphen ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
<div className="text-lg font-bold text-white font-mono">6 TLDs</div> )}
<div className="text-[10px] font-mono text-white/30"> >
.xyz .org .dev .app ... <div className="flex items-center gap-2">
</div> <Ban className="w-3.5 h-3.5 text-white/40" />
</div> <span className="text-[10px] font-mono text-white/60">No hyphens</span>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-3"> <div className={clsx("w-3.5 h-3.5 border flex items-center justify-center", excludeHyphen ? "border-accent bg-accent" : "border-white/30")}>
<div className="flex items-center gap-2 mb-1"> {excludeHyphen && <span className="text-black text-[8px] font-bold"></span>}
<Clock className="w-4 h-4 text-white/40" /> </div>
<span className="text-[10px] font-mono text-white/40 uppercase">Last Sync</span> </button>
</div>
<div className="text-sm font-bold text-white">
{stats.ch?.last_sync
? new Date(stats.ch.last_sync).toLocaleDateString()
: 'Pending'
}
</div>
<div className="text-[10px] font-mono text-white/30">
Daily @ 05:00 UTC
</div>
</div> </div>
</div> </div>
)} )}
{/* Search & Filters */}
<div className="space-y-3">
{/* Search */}
<div className={clsx(
"relative border transition-all duration-200",
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<div className="flex items-center">
<Search className={clsx("w-4 h-4 ml-3 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="Search dropped domains..."
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="p-3 text-white/30 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
)}
<button onClick={handleRefresh} disabled={refreshing} className="p-3 text-white/30 hover:text-white transition-colors">
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
</div>
</div>
{/* TLD Quick Filter */}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSelectedTld(null)}
className={clsx(
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
selectedTld === null ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
All
</button>
{/* Switch.ch TLDs */}
{[
{ tld: 'ch', flag: '🇨🇭' },
{ tld: 'li', flag: '🇱🇮' },
].map(({ tld, flag }) => (
<button
key={tld}
onClick={() => setSelectedTld(tld as 'ch' | 'li')}
className={clsx(
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors flex items-center gap-1.5",
selectedTld === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
<span>{flag}</span> .{tld}
</button>
))}
{/* Separator */}
<div className="w-px h-6 bg-white/10 self-center mx-1" />
{/* CZDS TLDs */}
{[
{ tld: 'xyz', flag: '🌐' },
{ tld: 'org', flag: '🏛️' },
{ tld: 'online', flag: '💻' },
{ tld: 'info', flag: '' },
{ tld: 'dev', flag: '👨‍💻' },
{ tld: 'app', flag: '📱' },
].map(({ tld, flag }) => (
<button
key={tld}
onClick={() => setSelectedTld(tld as any)}
className={clsx(
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors flex items-center gap-1.5",
selectedTld === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
<span>{flag}</span> .{tld}
</button>
))}
</div>
{/* Filter Toggle */}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
className={clsx(
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
filtersOpen ? "border-accent/30 bg-accent/[0.05]" : "border-white/[0.08] bg-white/[0.02]"
)}
>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Advanced Filters</span>
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>
)}
</div>
<ChevronRight className={clsx("w-4 h-4 text-white/30 transition-transform", filtersOpen && "rotate-90")} />
</button>
{/* Filters Panel */}
{filtersOpen && (
<div className="p-3 border border-white/[0.08] bg-white/[0.02] space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Time Range */}
<div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Time Range</div>
<div className="flex gap-2">
{[
{ value: 1, label: '24h' },
{ value: 7, label: '7 days' },
{ value: 14, label: '14 days' },
{ value: 30, label: '30 days' },
].map((item) => (
<button
key={item.value}
onClick={() => setDays(item.value)}
className={clsx(
"flex-1 py-1.5 text-[10px] font-mono border transition-colors",
days === item.value ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
{item.label}
</button>
))}
</div>
</div>
{/* Length Filter */}
<div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Domain Length</div>
<div className="flex gap-2 items-center">
<input
type="number"
value={minLength || ''}
onChange={(e) => setMinLength(e.target.value ? Number(e.target.value) : undefined)}
placeholder="Min"
className="w-20 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono"
min={1}
max={63}
/>
<span className="text-white/20"></span>
<input
type="number"
value={maxLength || ''}
onChange={(e) => setMaxLength(e.target.value ? Number(e.target.value) : undefined)}
placeholder="Max"
className="w-20 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono"
min={1}
max={63}
/>
<span className="text-[10px] font-mono text-white/30">characters</span>
</div>
</div>
{/* Quality Filters */}
<div className="flex gap-2">
<button
onClick={() => setExcludeNumeric(!excludeNumeric)}
className={clsx(
"flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
excludeNumeric ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
)}
>
<div className="flex items-center gap-2">
<Hash className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Exclude numeric</span>
</div>
<div className={clsx("w-4 h-4 border flex items-center justify-center", excludeNumeric ? "border-accent bg-accent" : "border-white/30")}>
{excludeNumeric && <span className="text-black text-[10px] font-bold"></span>}
</div>
</button>
<button
onClick={() => setExcludeHyphen(!excludeHyphen)}
className={clsx(
"flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
excludeHyphen ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
)}
>
<div className="flex items-center gap-2">
<Ban className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Exclude hyphens</span>
</div>
<div className={clsx("w-4 h-4 border flex items-center justify-center", excludeHyphen ? "border-accent bg-accent" : "border-white/30")}>
{excludeHyphen && <span className="text-black text-[10px] font-bold"></span>}
</div>
</button>
</div>
</div>
)}
</div>
{/* Stats Bar */} {/* Stats Bar */}
<div className="flex items-center justify-between text-[10px] font-mono text-white/40"> <div className="flex items-center justify-between text-[10px] font-mono text-white/40">
<span>{total.toLocaleString()} dropped domains found</span> <span>{total.toLocaleString()} fresh drops</span>
<span>Page {page}/{Math.max(1, totalPages)}</span> {totalPages > 1 && <span>Page {page}/{totalPages}</span>}
</div> </div>
{/* Results */} {/* Results */}
{sortedItems.length === 0 ? ( {sortedItems.length === 0 ? (
<div className="text-center py-16 border border-dashed border-white/[0.08]"> <div className="text-center py-16 border border-dashed border-white/[0.08]">
<Globe className="w-8 h-8 text-white/10 mx-auto mb-3" /> <Globe className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-white/40 text-sm font-mono">No dropped domains found</p> <p className="text-white/40 text-sm font-mono">No fresh drops</p>
<p className="text-white/25 text-xs font-mono mt-1"> <p className="text-white/25 text-xs font-mono mt-1">Check back after the next sync</p>
{stats?.weekly_drops === 0
? 'Zone file sync may be pending'
: 'Try adjusting your filters'}
</p>
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]"> <div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{/* Desktop Table Header */} {/* Desktop Table Header */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]"> <div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left"> <button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
Domain Domain
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)} {sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button> </button>
<button onClick={() => handleSort('length')} className="flex items-center gap-1 justify-center hover:text-white/60"> <button onClick={() => handleSort('length')} className="flex items-center gap-1 justify-center hover:text-white/60">
Length Len
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)} {sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button> </button>
<button onClick={() => handleSort('date')} className="flex items-center gap-1 justify-center hover:text-white/60"> <button onClick={() => handleSort('date')} className="flex items-center gap-1 justify-center hover:text-white/60">
Dropped When
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)} {sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button> </button>
<div className="text-right">Actions</div> <div className="text-right">Actions</div>
@ -506,21 +419,21 @@ export function DropsTab({ showToast }: DropsTabProps) {
<div key={item.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all"> <div key={item.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
{/* Mobile Row */} {/* Mobile Row */}
<div className="lg:hidden p-3"> <div className="lg:hidden p-3">
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<div className="w-8 h-8 flex items-center justify-center border bg-white/[0.02] border-white/[0.06] shrink-0"> <span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
<span className="text-sm">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span> <button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
</div> {item.domain}
<div className="min-w-0 flex-1"> </button>
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left"> </div>
{item.domain} <div className="flex items-center gap-2 shrink-0">
</button> <span className={clsx(
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30"> "text-[10px] font-mono font-bold px-1.5 py-0.5",
<span>{item.length} chars</span> item.length <= 5 ? "text-accent bg-accent/10" : "text-white/40 bg-white/5"
<span className="text-white/10">|</span> )}>
<span>{formatDate(item.dropped_date)}</span> {item.length}
</div> </span>
</div> <span className="text-[10px] font-mono text-white/30">{formatTime(item.dropped_date)}</span>
</div> </div>
</div> </div>
@ -528,34 +441,29 @@ export function DropsTab({ showToast }: DropsTabProps) {
<button <button
onClick={() => track(item.domain)} onClick={() => track(item.domain)}
disabled={tracking === item.domain} disabled={tracking === item.domain}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5 transition-all" className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5"
> >
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />} {tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
Track Track
</button> </button>
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 border border-white/[0.08] text-white/50 flex items-center justify-center"> <button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 border border-white/[0.08] text-white/50 flex items-center justify-center">
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
<a <a
href={`https://www.nic.${item.tld}/whois/?domain=${item.domain.split('.')[0]}`} href={`https://www.namecheap.com/domains/registration/results/?domain=${item.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5" className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase flex items-center justify-center gap-1"
> >
Register Get <ExternalLink className="w-3 h-3" />
<ExternalLink className="w-3 h-3" />
</a> </a>
</div> </div>
</div> </div>
{/* Desktop Row */} {/* Desktop Row */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 items-center p-3 group"> <div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 items-center p-3 group">
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<div className="w-8 h-8 flex items-center justify-center border bg-white/[0.02] border-white/[0.06] shrink-0"> <span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
<span className="text-sm">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
</div>
<button <button
onClick={() => openAnalyze(item.domain)} onClick={() => openAnalyze(item.domain)}
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left" className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
@ -566,7 +474,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
<div className="text-center"> <div className="text-center">
<span className={clsx( <span className={clsx(
"text-xs font-mono font-bold px-2 py-0.5", "text-[10px] font-mono font-bold px-1.5 py-0.5",
item.length <= 5 ? "text-accent bg-accent/10" : item.length <= 8 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5" item.length <= 5 ? "text-accent bg-accent/10" : item.length <= 8 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5"
)}> )}>
{item.length} {item.length}
@ -574,33 +482,30 @@ export function DropsTab({ showToast }: DropsTabProps) {
</div> </div>
<div className="text-center"> <div className="text-center">
<span className="text-xs font-mono text-white/50">{formatDate(item.dropped_date)}</span> <span className="text-[10px] font-mono text-white/50">{formatTime(item.dropped_date)}</span>
</div> </div>
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity"> <div className="flex items-center justify-end gap-1.5 opacity-50 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => track(item.domain)} onClick={() => track(item.domain)}
disabled={tracking === item.domain} disabled={tracking === item.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors" className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
> >
{tracking === item.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />} {tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
</button> </button>
<button <button
onClick={() => openAnalyze(item.domain)} onClick={() => openAnalyze(item.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors" className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
> >
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3 h-3" />
</button> </button>
<a <a
href={`https://www.nic.${item.tld}/whois/?domain=${item.domain.split('.')[0]}`} href={`https://www.namecheap.com/domains/registration/results/?domain=${item.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="h-7 px-3 bg-accent text-black text-xs font-bold flex items-center gap-1 hover:bg-white transition-colors" className="h-6 px-2 bg-accent text-black text-[10px] font-bold flex items-center gap-1 hover:bg-white"
> >
Get Get
<ExternalLink className="w-3 h-3" />
</a> </a>
</div> </div>
</div> </div>
@ -610,60 +515,26 @@ export function DropsTab({ showToast }: DropsTabProps) {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between pt-4"> <div className="flex items-center justify-center gap-1 pt-2">
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider"> <button
Page {page}/{totalPages} onClick={() => handlePageChange(page - 1)}
</div> disabled={page === 1}
<div className="flex items-center gap-1"> className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed"
<button >
onClick={() => handlePageChange(page - 1)} <ChevronLeft className="w-4 h-4" />
disabled={page === 1} </button>
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" <span className="text-xs text-white/50 font-mono px-3">{page}/{totalPages}</span>
> <button
<ChevronLeft className="w-4 h-4" /> onClick={() => handlePageChange(page + 1)}
</button> disabled={page === totalPages}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed"
<span className="text-xs text-white/50 font-mono px-2"> >
{page}/{totalPages} <ChevronRight className="w-4 h-4" />
</span> </button>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div> </div>
)} )}
</> </>
)} )}
{/* Info Box */}
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Download className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-sm font-bold text-white mb-1">Zone File Analysis</h3>
<p className="text-xs text-white/40 leading-relaxed mb-2">
Domains are detected by comparing daily zone file snapshots. Data sources:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[10px] font-mono">
<div className="flex items-center gap-2 text-white/50">
<span>🇨🇭</span>
<span><strong>.ch/.li</strong> via Switch.ch (AXFR)</span>
</div>
<div className="flex items-center gap-2 text-white/50">
<span>🌐</span>
<span><strong>gTLDs</strong> via ICANN CZDS</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
) )
} }

View File

@ -331,55 +331,6 @@ export function SearchTab({ showToast }: SearchTabProps) {
</div> </div>
)} )}
{/* Quick Tips */}
{!searchResult && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-accent/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center group-hover:bg-accent/20 transition-colors">
<Globe className="w-5 h-5 text-accent" />
</div>
<div>
<div className="text-sm font-bold text-white">Instant Check</div>
<div className="text-[10px] font-mono text-white/40">RDAP/WHOIS</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Check any domain's availability in real-time using RDAP/WHOIS protocols.
</p>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:bg-white/[0.05] transition-colors">
<Eye className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Monitor Drops</div>
<div className="text-[10px] font-mono text-white/40">Watchlist</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Add taken domains to your watchlist. Get alerted when they become available.
</p>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:bg-white/[0.05] transition-colors">
<Shield className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Deep Analysis</div>
<div className="text-[10px] font-mono text-white/40">Full report</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Run full analysis: backlinks, SEO metrics, history, trademark checks.
</p>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -1839,13 +1839,13 @@ class AdminApiClient extends ApiClient {
return this.request<{ return this.request<{
ch: { domain_count: number; last_sync: string | null } ch: { domain_count: number; last_sync: string | null }
li: { domain_count: number; last_sync: string | null } li: { domain_count: number; last_sync: string | null }
weekly_drops: number daily_drops: number
}>('/drops/stats') }>('/drops/stats')
} }
async getDrops(params?: { async getDrops(params?: {
tld?: 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app' tld?: 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
days?: number hours?: number
min_length?: number min_length?: number
max_length?: number max_length?: number
exclude_numeric?: boolean exclude_numeric?: boolean
@ -1856,7 +1856,7 @@ class AdminApiClient extends ApiClient {
}) { }) {
const query = new URLSearchParams() const query = new URLSearchParams()
if (params?.tld) query.set('tld', params.tld) if (params?.tld) query.set('tld', params.tld)
if (params?.days) query.set('days', params.days.toString()) if (params?.hours) query.set('hours', params.hours.toString())
if (params?.min_length) query.set('min_length', params.min_length.toString()) if (params?.min_length) query.set('min_length', params.min_length.toString())
if (params?.max_length) query.set('max_length', params.max_length.toString()) if (params?.max_length) query.set('max_length', params.max_length.toString())
if (params?.exclude_numeric) query.set('exclude_numeric', 'true') if (params?.exclude_numeric) query.set('exclude_numeric', 'true')