feat: optimize drops to 24h only, award-winning analyze panel
Some checks are pending
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
CI / Backend Lint (push) Waiting to run
CI / Backend Tests (push) Blocked by required conditions
CI / Docker Build (push) Blocked by required conditions
CI / Security Scan (push) Waiting to run
Deploy / Build & Push Images (push) Waiting to run
Deploy / Deploy to Server (push) Blocked by required conditions
Deploy / Notify (push) Blocked by required conditions

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("")
async def api_get_drops(
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"),
max_length: Optional[int] = Query(None, ge=1, le=63, description="Maximum domain length"),
exclude_numeric: bool = Query(False, description="Exclude numeric-only domains"),
@ -86,7 +86,7 @@ async def api_get_drops(
result = await get_dropped_domains(
db=db,
tld=tld,
days=days,
hours=hours,
min_length=min_length,
max_length=max_length,
exclude_numeric=exclude_numeric,

View File

@ -675,6 +675,15 @@ def setup_scheduler():
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(
f"Scheduler configured:"
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}")
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():
"""Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs)."""
logger.info("Starting zone file sync...")

View File

@ -256,7 +256,7 @@ class ZoneFileService:
async def get_dropped_domains(
db: AsyncSession,
tld: Optional[str] = None,
days: int = 7,
hours: int = 24,
min_length: Optional[int] = None,
max_length: Optional[int] = None,
exclude_numeric: bool = False,
@ -267,8 +267,9 @@ async def get_dropped_domains(
) -> dict:
"""
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)
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()
li_snapshot = li_result.scalar_one_or_none()
# Count recent drops
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = today - timedelta(days=7)
# Count drops from last 24h only
cutoff_24h = datetime.utcnow() - timedelta(hours=24)
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)
weekly_drops = drops_result.scalar() or 0
daily_drops = drops_result.scalar() or 0
return {
"ch": {
@ -353,5 +353,48 @@ async def get_zone_stats(db: AsyncSession) -> dict:
"domain_count": li_snapshot.domain_count if li_snapshot else 0,
"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 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 { useAnalyzePanelStore } from '@/lib/analyze-store'
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
function statusPill(status: string) {
// ============================================================================
// HELPERS
// ============================================================================
function getStatusColor(status: string) {
switch (status) {
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':
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':
return 'bg-red-500/10 text-red-300 border-red-500/20'
case 'na':
return 'bg-white/5 text-white/30 border-white/10'
return { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30', icon: X }
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 {
if (value === null || value === undefined) return 'N/A'
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
@ -40,28 +90,35 @@ function formatValue(value: unknown): string {
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) {
return item.key === 'tld_matrix' && Array.isArray(item.value)
}
// ============================================================================
// COMPONENT
// ============================================================================
export function AnalyzePanel() {
const { isOpen, domain, close, fastMode, setFastMode, filterText, setFilterText, sectionVisibility, setSectionVisibility } =
useAnalyzePanelStore()
const {
isOpen,
domain,
close,
fastMode,
setFastMode,
sectionVisibility,
setSectionVisibility
} = useAnalyzePanelStore()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<AnalyzeResponse | null>(null)
const [copied, setCopied] = useState(false)
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
authority: true,
market: true,
risk: true,
value: true
})
const refresh = useCallback(async () => {
if (!domain) return
@ -97,9 +154,7 @@ export function AnalyzePanel() {
}
}
run()
return () => {
cancelled = true
}
return () => { cancelled = true }
}, [isOpen, domain, fastMode])
// ESC to close
@ -112,210 +167,296 @@ export function AnalyzePanel() {
return () => window.removeEventListener('keydown', onKey)
}, [isOpen, close])
const toggleSection = useCallback((key: string) => {
setExpandedSections(prev => ({ ...prev, [key]: !prev[key] }))
}, [])
const visibleSections = useMemo(() => {
const sections = data?.sections || []
const order = ['authority', 'market', 'risk', 'value']
const ordered = [...sections].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
return ordered
return [...sections]
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
.filter((s) => sectionVisibility[s.key] !== false)
.map((s) => filterSection(s, filterText))
.filter((s) => s.items.length > 0 || !filterText.trim())
}, [data, sectionVisibility, filterText])
}, [data, sectionVisibility])
// 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 computedAt = data?.computed_at ? new Date(data.computed_at).toLocaleString() : null
if (!isOpen) return null
return (
<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} />
<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">
{/* 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">
{/* Header */}
<div className="px-4 py-3 border-b border-white/[0.08] flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
<div className="text-sm font-bold text-white font-mono truncate">ANALYZE</div>
{data?.cached ? (
<span className="text-[9px] font-mono px-1.5 py-0.5 border border-white/10 text-white/40">CACHED</span>
) : null}
<div className="shrink-0 border-b border-white/[0.06]">
{/* Top Bar */}
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
<Shield className="w-5 h-5 text-accent" />
</div>
<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 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
onClick={async () => {
const ok = await copyToClipboard(headerDomain)
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"
title="Copy domain"
className={clsx(
"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>
{copied ? <span className="text-[10px] font-mono text-accent">Copied</span> : null}
</div>
{computedAt ? <div className="text-[10px] text-white/30 font-mono mt-1">Computed: {computedAt}</div> : null}
</div>
<div className="flex items-center gap-2">
<a
href={`https://${encodeURIComponent(headerDomain)}`}
target="_blank"
rel="noopener noreferrer"
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors"
title="Open in browser"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={refresh}
disabled={loading}
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
</button>
<button onClick={close} className="p-2 border border-white/10 text-white/60 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Controls */}
<div className="px-4 py-3 border-b border-white/[0.08]">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-white/25 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
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"
/>
{/* Score Bar */}
{overallScore && !loading && (
<div className="px-4 pb-3">
<div className="flex items-center gap-3">
<div className={clsx(
"text-2xl font-bold font-mono",
overallScore.score >= 70 ? "text-accent" : overallScore.score >= 40 ? "text-amber-400" : "text-red-400"
)}>
{overallScore.score}
</div>
<div className="flex-1">
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden flex">
<div
className="h-full bg-accent transition-all"
style={{ width: `${(overallScore.pass / overallScore.total) * 100}%` }}
/>
<div
className="h-full bg-amber-400 transition-all"
style={{ width: `${(overallScore.warn / overallScore.total) * 100}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${(overallScore.fail / overallScore.total) * 100}%` }}
/>
</div>
</div>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="text-accent">{overallScore.pass} pass</span>
<span className="text-amber-400">{overallScore.warn} warn</span>
<span className="text-red-400">{overallScore.fail} fail</span>
</div>
</div>
</div>
)}
{/* Mode Toggle */}
<div className="px-4 pb-3 flex items-center gap-2">
<button
onClick={() => setFastMode(!fastMode)}
className={clsx(
'px-3 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center gap-1.5 transition-all font-mono',
fastMode ? 'border-accent/30 bg-accent/10 text-accent' : 'border-white/10 text-white/50 hover:text-white'
"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/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
</button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{(['authority', 'market', 'risk', 'value'] as const).map((key) => {
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>
)
})}
{data?.cached && (
<span className="text-[10px] font-mono text-white/30 px-2 py-1 border border-white/10">
Cached
</span>
)}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-6 text-white/40 font-mono text-sm">Loading</div>
) : error ? (
<div className="p-6">
<div className="text-sm font-mono text-red-300 mb-2">Analyze failed</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 className="flex items-center justify-center py-20">
<div className="text-center">
<RefreshCw className="w-6 h-6 text-accent animate-spin mx-auto mb-3" />
<div className="text-sm font-mono text-white/40">Analyzing...</div>
</div>
</div>
)}
</div>
) : error ? (
<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 */}
<div className="px-4 py-3 border-t border-white/[0.08] bg-white/[0.02]">
<div className="text-[10px] font-mono text-white/30">
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.
</div>
return (
<div key={section.key} className="border border-white/[0.06] bg-[#020202] overflow-hidden">
{/* Section Header */}
<button
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>
)
}

View File

@ -350,55 +350,6 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
</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>
)
}

View File

@ -5,12 +5,10 @@ import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store'
import {
Download,
Clock,
Globe,
Loader2,
Search,
Filter,
ChevronRight,
ChevronLeft,
ChevronUp,
@ -21,7 +19,7 @@ import {
Shield,
ExternalLink,
Zap,
Calendar,
Filter,
Ban,
Hash,
} from 'lucide-react'
@ -43,9 +41,23 @@ interface DroppedDomain {
interface ZoneStats {
ch: { 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
// ============================================================================
@ -65,14 +77,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
const [refreshing, setRefreshing] = useState(false)
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 [searchQuery, setSearchQuery] = useState('')
const [searchFocused, setSearchFocused] = useState(false)
const [days, setDays] = useState(7)
const [minLength, setMinLength] = useState<number | undefined>(undefined)
const [maxLength, setMaxLength] = useState<number | undefined>(undefined)
const [excludeNumeric, setExcludeNumeric] = useState(true)
@ -84,8 +92,8 @@ export function DropsTab({ showToast }: DropsTabProps) {
const ITEMS_PER_PAGE = 50
// Sorting
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('date')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('length')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// Tracking
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) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
@ -108,7 +116,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
try {
const result = await api.getDrops({
tld: selectedTld || undefined,
days,
hours: 24, // Only last 24h - fresh drops only!
min_length: minLength,
max_length: maxLength,
exclude_numeric: excludeNumeric,
@ -121,7 +129,6 @@ export function DropsTab({ showToast }: DropsTabProps) {
setTotal(result.total)
} catch (error: any) {
console.error('Failed to load drops:', error)
// If API returns error, show info message
if (error.message?.includes('401') || error.message?.includes('auth')) {
showToast('Login required to view drops', 'info')
}
@ -131,7 +138,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
setLoading(false)
setRefreshing(false)
}
}, [selectedTld, days, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
}, [selectedTld, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
// Initial Load
useEffect(() => {
@ -199,12 +206,15 @@ export function DropsTab({ showToast }: DropsTabProps) {
maxLength !== undefined,
excludeNumeric,
excludeHyphen,
days !== 7
].filter(Boolean).length
const formatDate = (iso: string) => {
const formatTime = (iso: string) => {
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) {
@ -217,286 +227,189 @@ export function DropsTab({ showToast }: DropsTabProps) {
return (
<div className="space-y-4">
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="border border-accent/20 bg-accent/[0.03] p-3">
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-accent" />
<span className="text-[10px] font-mono text-accent/60 uppercase">Weekly Drops</span>
{/* Header Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-accent" />
</div>
<div>
<div className="text-xl font-bold text-white font-mono">
{stats?.daily_drops?.toLocaleString() || total.toLocaleString()}
</div>
<div className="text-xl font-bold text-accent font-mono">
{stats.weekly_drops.toLocaleString()}
</div>
<div className="text-[10px] font-mono text-white/30">
Last 7 days
<div className="text-[10px] font-mono text-white/40 uppercase">Fresh drops (24h)</div>
</div>
</div>
<button
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 className="border border-white/[0.08] bg-[#020202] p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm">🇨🇭🇱🇮</span>
<span className="text-[10px] font-mono text-white/40 uppercase">Switch.ch</span>
</div>
<div className="text-lg font-bold text-white font-mono">
{((stats.ch?.domain_count || 0) + (stats.li?.domain_count || 0)).toLocaleString()}
</div>
<div className="text-[10px] font-mono text-white/30">
.ch + .li zones
</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-3.5 h-3.5 text-white/40" />
<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">
<div className="flex items-center gap-2 mb-1">
<Globe className="w-4 h-4 text-white/40" />
<span className="text-[10px] font-mono text-white/40 uppercase">ICANN CZDS</span>
</div>
<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>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-3">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-white/40" />
<span className="text-[10px] font-mono text-white/40 uppercase">Last Sync</span>
</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>
<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-3.5 h-3.5 text-white/40" />
<span className="text-[10px] font-mono text-white/60">No hyphens</span>
</div>
<div className={clsx("w-3.5 h-3.5 border flex items-center justify-center", excludeHyphen ? "border-accent bg-accent" : "border-white/30")}>
{excludeHyphen && <span className="text-black text-[8px] font-bold"></span>}
</div>
</button>
</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 */}
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
<span>{total.toLocaleString()} dropped domains found</span>
<span>Page {page}/{Math.max(1, totalPages)}</span>
<span>{total.toLocaleString()} fresh drops</span>
{totalPages > 1 && <span>Page {page}/{totalPages}</span>}
</div>
{/* Results */}
{sortedItems.length === 0 ? (
<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" />
<p className="text-white/40 text-sm font-mono">No dropped domains found</p>
<p className="text-white/25 text-xs font-mono mt-1">
{stats?.weekly_drops === 0
? 'Zone file sync may be pending'
: 'Try adjusting your filters'}
</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">Check back after the next sync</p>
</div>
) : (
<>
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{/* 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">
Domain
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button>
<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" />)}
</button>
<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" />)}
</button>
<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">
{/* Mobile Row */}
<div className="lg:hidden p-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-3 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">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
</div>
<div className="min-w-0 flex-1">
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
{item.domain}
</button>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
<span>{item.length} chars</span>
<span className="text-white/10">|</span>
<span>{formatDate(item.dropped_date)}</span>
</div>
</div>
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
{item.domain}
</button>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={clsx(
"text-[10px] font-mono font-bold px-1.5 py-0.5",
item.length <= 5 ? "text-accent bg-accent/10" : "text-white/40 bg-white/5"
)}>
{item.length}
</span>
<span className="text-[10px] font-mono text-white/30">{formatTime(item.dropped_date)}</span>
</div>
</div>
@ -528,34 +441,29 @@ export function DropsTab({ showToast }: DropsTabProps) {
<button
onClick={() => track(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" />}
Track
</button>
<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" />
</button>
<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"
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
<ExternalLink className="w-3 h-3" />
Get <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
{/* Desktop Row */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 items-center p-3 group">
<div className="flex items-center gap-3 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">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
</div>
<div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 items-center p-3 group">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
<button
onClick={() => openAnalyze(item.domain)}
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">
<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}
@ -574,33 +482,30 @@ export function DropsTab({ showToast }: DropsTabProps) {
</div>
<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 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
onClick={() => track(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
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>
<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"
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
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
@ -610,60 +515,26 @@ export function DropsTab({ showToast }: DropsTabProps) {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider">
Page {page}/{totalPages}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 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 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs text-white/50 font-mono px-2">
{page}/{totalPages}
</span>
<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 className="flex items-center justify-center gap-1 pt-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 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"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs text-white/50 font-mono px-3">{page}/{totalPages}</span>
<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"
>
<ChevronRight className="w-4 h-4" />
</button>
</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>
)
}

View File

@ -331,55 +331,6 @@ export function SearchTab({ showToast }: SearchTabProps) {
</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>
)
}

View File

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