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
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:
@ -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,
|
||||
|
||||
@ -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...")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />
|
||||
|
||||
{/* 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 */}
|
||||
<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; we’ll 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
Reference in New Issue
Block a user