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("")
|
@router.get("")
|
||||||
async def api_get_drops(
|
async def api_get_drops(
|
||||||
tld: Optional[str] = Query(None, description="Filter by TLD"),
|
tld: Optional[str] = Query(None, description="Filter by TLD"),
|
||||||
days: int = Query(7, ge=1, le=30, description="Days to look back"),
|
hours: int = Query(24, ge=1, le=48, description="Hours to look back (max 48h, we only store 48h)"),
|
||||||
min_length: Optional[int] = Query(None, ge=1, le=63, description="Minimum domain length"),
|
min_length: Optional[int] = Query(None, ge=1, le=63, description="Minimum domain length"),
|
||||||
max_length: Optional[int] = Query(None, ge=1, le=63, description="Maximum domain length"),
|
max_length: Optional[int] = Query(None, ge=1, le=63, description="Maximum domain length"),
|
||||||
exclude_numeric: bool = Query(False, description="Exclude numeric-only domains"),
|
exclude_numeric: bool = Query(False, description="Exclude numeric-only domains"),
|
||||||
@ -86,7 +86,7 @@ async def api_get_drops(
|
|||||||
result = await get_dropped_domains(
|
result = await get_dropped_domains(
|
||||||
db=db,
|
db=db,
|
||||||
tld=tld,
|
tld=tld,
|
||||||
days=days,
|
hours=hours,
|
||||||
min_length=min_length,
|
min_length=min_length,
|
||||||
max_length=max_length,
|
max_length=max_length,
|
||||||
exclude_numeric=exclude_numeric,
|
exclude_numeric=exclude_numeric,
|
||||||
|
|||||||
@ -675,6 +675,15 @@ def setup_scheduler():
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Zone data cleanup (hourly - delete drops older than 48h)
|
||||||
|
scheduler.add_job(
|
||||||
|
cleanup_zone_data,
|
||||||
|
CronTrigger(minute=45), # Every hour at :45
|
||||||
|
id="zone_cleanup",
|
||||||
|
name="Zone Data Cleanup (hourly)",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Scheduler configured:"
|
f"Scheduler configured:"
|
||||||
f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)"
|
f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)"
|
||||||
@ -850,6 +859,26 @@ async def scrape_auctions():
|
|||||||
logger.exception(f"Auction scrape failed: {e}")
|
logger.exception(f"Auction scrape failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_zone_data():
|
||||||
|
"""Clean up old zone file data to save storage."""
|
||||||
|
logger.info("Starting zone data cleanup...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.zone_file import cleanup_old_drops, cleanup_old_snapshots
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Delete drops older than 48h
|
||||||
|
drops_deleted = await cleanup_old_drops(db, hours=48)
|
||||||
|
|
||||||
|
# Delete snapshots older than 7 days
|
||||||
|
snapshots_deleted = await cleanup_old_snapshots(db, keep_days=7)
|
||||||
|
|
||||||
|
logger.info(f"Zone cleanup: {drops_deleted} drops, {snapshots_deleted} snapshots deleted")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Zone data cleanup failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def sync_zone_files():
|
async def sync_zone_files():
|
||||||
"""Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs)."""
|
"""Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs)."""
|
||||||
logger.info("Starting zone file sync...")
|
logger.info("Starting zone file sync...")
|
||||||
|
|||||||
@ -256,7 +256,7 @@ class ZoneFileService:
|
|||||||
async def get_dropped_domains(
|
async def get_dropped_domains(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tld: Optional[str] = None,
|
tld: Optional[str] = None,
|
||||||
days: int = 7,
|
hours: int = 24,
|
||||||
min_length: Optional[int] = None,
|
min_length: Optional[int] = None,
|
||||||
max_length: Optional[int] = None,
|
max_length: Optional[int] = None,
|
||||||
exclude_numeric: bool = False,
|
exclude_numeric: bool = False,
|
||||||
@ -267,8 +267,9 @@ async def get_dropped_domains(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get recently dropped domains with filters.
|
Get recently dropped domains with filters.
|
||||||
|
Only returns drops from last 24-48h (we don't store older data).
|
||||||
"""
|
"""
|
||||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||||
|
|
||||||
query = select(DroppedDomain).where(DroppedDomain.dropped_date >= cutoff)
|
query = select(DroppedDomain).where(DroppedDomain.dropped_date >= cutoff)
|
||||||
count_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff)
|
count_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff)
|
||||||
@ -336,13 +337,12 @@ async def get_zone_stats(db: AsyncSession) -> dict:
|
|||||||
ch_snapshot = ch_result.scalar_one_or_none()
|
ch_snapshot = ch_result.scalar_one_or_none()
|
||||||
li_snapshot = li_result.scalar_one_or_none()
|
li_snapshot = li_result.scalar_one_or_none()
|
||||||
|
|
||||||
# Count recent drops
|
# Count drops from last 24h only
|
||||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
cutoff_24h = datetime.utcnow() - timedelta(hours=24)
|
||||||
week_ago = today - timedelta(days=7)
|
|
||||||
|
|
||||||
drops_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= week_ago)
|
drops_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff_24h)
|
||||||
drops_result = await db.execute(drops_query)
|
drops_result = await db.execute(drops_query)
|
||||||
weekly_drops = drops_result.scalar() or 0
|
daily_drops = drops_result.scalar() or 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ch": {
|
"ch": {
|
||||||
@ -353,5 +353,48 @@ async def get_zone_stats(db: AsyncSession) -> dict:
|
|||||||
"domain_count": li_snapshot.domain_count if li_snapshot else 0,
|
"domain_count": li_snapshot.domain_count if li_snapshot else 0,
|
||||||
"last_sync": li_snapshot.snapshot_date.isoformat() if li_snapshot else None
|
"last_sync": li_snapshot.snapshot_date.isoformat() if li_snapshot else None
|
||||||
},
|
},
|
||||||
"weekly_drops": weekly_drops
|
"daily_drops": daily_drops
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_old_drops(db: AsyncSession, hours: int = 48) -> int:
|
||||||
|
"""
|
||||||
|
Delete dropped domains older than specified hours.
|
||||||
|
Default: Keep only last 48h for safety margin (24h display + 24h buffer).
|
||||||
|
Returns number of deleted records.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# Delete old drops
|
||||||
|
stmt = delete(DroppedDomain).where(DroppedDomain.dropped_date < cutoff)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
deleted = result.rowcount
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Cleaned up {deleted} old dropped domains (older than {hours}h)")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_old_snapshots(db: AsyncSession, keep_days: int = 7) -> int:
|
||||||
|
"""
|
||||||
|
Delete zone snapshots older than specified days.
|
||||||
|
Keep at least 7 days of metadata for debugging.
|
||||||
|
Returns number of deleted records.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=keep_days)
|
||||||
|
|
||||||
|
stmt = delete(ZoneSnapshot).where(ZoneSnapshot.snapshot_date < cutoff)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
deleted = result.rowcount
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Cleaned up {deleted} old zone snapshots (older than {keep_days}d)")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|||||||
@ -2,23 +2,73 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { X, RefreshCw, Search, Shield, Zap, Copy, ExternalLink } from 'lucide-react'
|
import {
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
Shield,
|
||||||
|
TrendingUp,
|
||||||
|
AlertTriangle,
|
||||||
|
DollarSign,
|
||||||
|
Check,
|
||||||
|
Zap,
|
||||||
|
Globe,
|
||||||
|
Calendar,
|
||||||
|
Link2,
|
||||||
|
Radio,
|
||||||
|
Eye,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
|
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
|
||||||
|
|
||||||
function statusPill(status: string) {
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return 'bg-accent/10 text-accent border-accent/20'
|
return { bg: 'bg-accent/10', text: 'text-accent', border: 'border-accent/30', icon: Check }
|
||||||
case 'warn':
|
case 'warn':
|
||||||
return 'bg-amber-400/10 text-amber-300 border-amber-400/20'
|
return { bg: 'bg-amber-400/10', text: 'text-amber-300', border: 'border-amber-400/30', icon: AlertTriangle }
|
||||||
case 'fail':
|
case 'fail':
|
||||||
return 'bg-red-500/10 text-red-300 border-red-500/20'
|
return { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30', icon: X }
|
||||||
case 'na':
|
|
||||||
return 'bg-white/5 text-white/30 border-white/10'
|
|
||||||
default:
|
default:
|
||||||
return 'bg-white/5 text-white/50 border-white/10'
|
return { bg: 'bg-white/5', text: 'text-white/40', border: 'border-white/10', icon: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSectionIcon(key: string) {
|
||||||
|
switch (key) {
|
||||||
|
case 'authority':
|
||||||
|
return Shield
|
||||||
|
case 'market':
|
||||||
|
return TrendingUp
|
||||||
|
case 'risk':
|
||||||
|
return AlertTriangle
|
||||||
|
case 'value':
|
||||||
|
return DollarSign
|
||||||
|
default:
|
||||||
|
return Globe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSectionColor(key: string) {
|
||||||
|
switch (key) {
|
||||||
|
case 'authority':
|
||||||
|
return 'text-blue-400'
|
||||||
|
case 'market':
|
||||||
|
return 'text-emerald-400'
|
||||||
|
case 'risk':
|
||||||
|
return 'text-amber-400'
|
||||||
|
case 'value':
|
||||||
|
return 'text-violet-400'
|
||||||
|
default:
|
||||||
|
return 'text-white/60'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +82,7 @@ async function copyToClipboard(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
function formatValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) return 'N/A'
|
if (value === null || value === undefined) return '—'
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
if (typeof value === 'number') return String(value)
|
if (typeof value === 'number') return String(value)
|
||||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
@ -40,28 +90,35 @@ function formatValue(value: unknown): string {
|
|||||||
return 'Details'
|
return 'Details'
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterSection(section: AnalyzeSection, filterText: string): AnalyzeSection {
|
|
||||||
const f = filterText.trim().toLowerCase()
|
|
||||||
if (!f) return section
|
|
||||||
const items = section.items.filter((it) => {
|
|
||||||
const base = `${it.label} ${it.key} ${formatValue(it.value)}`.toLowerCase()
|
|
||||||
return base.includes(f)
|
|
||||||
})
|
|
||||||
return { ...section, items }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMatrix(item: AnalyzeItem) {
|
function isMatrix(item: AnalyzeItem) {
|
||||||
return item.key === 'tld_matrix' && Array.isArray(item.value)
|
return item.key === 'tld_matrix' && Array.isArray(item.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export function AnalyzePanel() {
|
export function AnalyzePanel() {
|
||||||
const { isOpen, domain, close, fastMode, setFastMode, filterText, setFilterText, sectionVisibility, setSectionVisibility } =
|
const {
|
||||||
useAnalyzePanelStore()
|
isOpen,
|
||||||
|
domain,
|
||||||
|
close,
|
||||||
|
fastMode,
|
||||||
|
setFastMode,
|
||||||
|
sectionVisibility,
|
||||||
|
setSectionVisibility
|
||||||
|
} = useAnalyzePanelStore()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [data, setData] = useState<AnalyzeResponse | null>(null)
|
const [data, setData] = useState<AnalyzeResponse | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
authority: true,
|
||||||
|
market: true,
|
||||||
|
risk: true,
|
||||||
|
value: true
|
||||||
|
})
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!domain) return
|
if (!domain) return
|
||||||
@ -97,9 +154,7 @@ export function AnalyzePanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
run()
|
run()
|
||||||
return () => {
|
return () => { cancelled = true }
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [isOpen, domain, fastMode])
|
}, [isOpen, domain, fastMode])
|
||||||
|
|
||||||
// ESC to close
|
// ESC to close
|
||||||
@ -112,210 +167,296 @@ export function AnalyzePanel() {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [isOpen, close])
|
}, [isOpen, close])
|
||||||
|
|
||||||
|
const toggleSection = useCallback((key: string) => {
|
||||||
|
setExpandedSections(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const visibleSections = useMemo(() => {
|
const visibleSections = useMemo(() => {
|
||||||
const sections = data?.sections || []
|
const sections = data?.sections || []
|
||||||
const order = ['authority', 'market', 'risk', 'value']
|
const order = ['authority', 'market', 'risk', 'value']
|
||||||
const ordered = [...sections].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
|
return [...sections]
|
||||||
return ordered
|
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
|
||||||
.filter((s) => sectionVisibility[s.key] !== false)
|
.filter((s) => sectionVisibility[s.key] !== false)
|
||||||
.map((s) => filterSection(s, filterText))
|
}, [data, sectionVisibility])
|
||||||
.filter((s) => s.items.length > 0 || !filterText.trim())
|
|
||||||
}, [data, sectionVisibility, filterText])
|
// Calculate overall score
|
||||||
|
const overallScore = useMemo(() => {
|
||||||
|
if (!data?.sections) return null
|
||||||
|
let pass = 0, warn = 0, fail = 0
|
||||||
|
data.sections.forEach(s => {
|
||||||
|
s.items.forEach(item => {
|
||||||
|
if (item.status === 'pass') pass++
|
||||||
|
else if (item.status === 'warn') warn++
|
||||||
|
else if (item.status === 'fail') fail++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const total = pass + warn + fail
|
||||||
|
if (total === 0) return null
|
||||||
|
const score = Math.round((pass * 100 + warn * 50) / total)
|
||||||
|
return { score, pass, warn, fail, total }
|
||||||
|
}, [data])
|
||||||
|
|
||||||
const headerDomain = data?.domain || domain || ''
|
const headerDomain = data?.domain || domain || ''
|
||||||
const computedAt = data?.computed_at ? new Date(data.computed_at).toLocaleString() : null
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200]">
|
<div className="fixed inset-0 z-[200]">
|
||||||
<div className="absolute inset-0 bg-black/80" onClick={close} />
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={close} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[480px] bg-[#050505] border-l border-white/[0.06] flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[520px] bg-[#0A0A0A] border-l border-white/[0.08] flex flex-col">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-white/[0.08] flex items-start gap-3">
|
<div className="shrink-0 border-b border-white/[0.06]">
|
||||||
<div className="flex-1 min-w-0">
|
{/* Top Bar */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<Shield className="w-4 h-4 text-accent" />
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm font-bold text-white font-mono truncate">ANALYZE</div>
|
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
{data?.cached ? (
|
<Shield className="w-5 h-5 text-accent" />
|
||||||
<span className="text-[9px] font-mono px-1.5 py-0.5 border border-white/10 text-white/40">CACHED</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div>
|
||||||
<div className="text-[12px] text-white/70 font-mono truncate">{headerDomain}</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="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const ok = await copyToClipboard(headerDomain)
|
const ok = await copyToClipboard(headerDomain)
|
||||||
setCopied(ok)
|
setCopied(ok)
|
||||||
window.setTimeout(() => setCopied(false), 900)
|
setTimeout(() => setCopied(false), 1500)
|
||||||
}}
|
}}
|
||||||
className="p-1 border border-white/10 text-white/50 hover:text-white transition-colors"
|
className={clsx(
|
||||||
title="Copy domain"
|
"w-8 h-8 flex items-center justify-center border transition-all",
|
||||||
|
copied ? "border-accent bg-accent/10 text-accent" : "border-white/10 text-white/40 hover:text-white"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Copy className="w-3.5 h-3.5" />
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
</button>
|
</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
|
<a
|
||||||
href={`https://${encodeURIComponent(headerDomain)}`}
|
href={`https://${encodeURIComponent(headerDomain)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors"
|
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white transition-colors"
|
||||||
title="Open in browser"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors disabled:opacity-50"
|
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"
|
||||||
title="Refresh"
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
|
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={close} className="p-2 border border-white/10 text-white/60 hover:text-white transition-colors">
|
<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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Score Bar */}
|
||||||
<div className="px-4 py-3 border-b border-white/[0.08]">
|
{overallScore && !loading && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="px-4 pb-3">
|
||||||
<div className="relative flex-1">
|
<div className="flex items-center gap-3">
|
||||||
<Search className="w-4 h-4 text-white/25 absolute left-3 top-1/2 -translate-y-1/2" />
|
<div className={clsx(
|
||||||
<input
|
"text-2xl font-bold font-mono",
|
||||||
value={filterText}
|
overallScore.score >= 70 ? "text-accent" : overallScore.score >= 40 ? "text-amber-400" : "text-red-400"
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
)}>
|
||||||
placeholder="Filter signals…"
|
{overallScore.score}
|
||||||
className="w-full bg-white/[0.02] border border-white/10 pl-9 pr-3 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono"
|
</div>
|
||||||
|
<div 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>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => setFastMode(!fastMode)}
|
onClick={() => setFastMode(!fastMode)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'px-3 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center gap-1.5 transition-all font-mono',
|
"flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition-all",
|
||||||
fastMode ? 'border-accent/30 bg-accent/10 text-accent' : 'border-white/10 text-white/50 hover:text-white'
|
fastMode
|
||||||
|
? "border-accent/30 bg-accent/10 text-accent"
|
||||||
|
: "border-white/10 text-white/40 hover:text-white"
|
||||||
)}
|
)}
|
||||||
title="Fast mode skips slower HTTP/SSL checks"
|
|
||||||
>
|
>
|
||||||
<Zap className="w-3.5 h-3.5" />
|
<Zap className="w-3 h-3" />
|
||||||
Fast
|
Fast
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{data?.cached && (
|
||||||
|
<span className="text-[10px] font-mono text-white/30 px-2 py-1 border border-white/10">
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
Cached
|
||||||
{(['authority', 'market', 'risk', 'value'] as const).map((key) => {
|
</span>
|
||||||
const on = sectionVisibility[key] !== false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setSectionVisibility({ ...sectionVisibility, [key]: !on })}
|
|
||||||
className={clsx(
|
|
||||||
'px-2 py-1 text-[10px] font-mono border transition-colors',
|
|
||||||
on ? 'border-white/10 text-white/50 hover:text-white' : 'border-white/10 text-white/25 bg-white/[0.02]'
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-6 text-white/40 font-mono text-sm">Loading…</div>
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<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>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
<div className="text-sm font-mono text-red-300 mb-2">Analyze failed</div>
|
<div className="border border-red-500/20 bg-red-500/5 p-4">
|
||||||
<div className="text-[12px] text-white/40 font-mono break-words">{error}</div>
|
<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>
|
</div>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<div className="p-6 text-white/40 font-mono text-sm">No data.</div>
|
<div className="flex items-center justify-center py-20">
|
||||||
) : (
|
<div className="text-sm font-mono text-white/30">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>
|
||||||
<div className="divide-y divide-white/[0.06]">
|
) : (
|
||||||
{section.items.map((it) => (
|
<div className="p-3 space-y-2">
|
||||||
<div key={it.key} className="px-3 py-2 flex items-start gap-3">
|
{visibleSections.map((section) => {
|
||||||
|
const SectionIcon = getSectionIcon(section.key)
|
||||||
|
const sectionColor = getSectionColor(section.key)
|
||||||
|
const isExpanded = expandedSections[section.key] !== false
|
||||||
|
|
||||||
|
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-1 min-w-0">
|
||||||
<div className="text-[11px] font-mono text-white/70">{it.label}</div>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-[12px] font-mono text-white/40 mt-1 break-words">
|
<span className="text-[11px] font-medium text-white/70">
|
||||||
{isMatrix(it) ? (
|
{item.label}
|
||||||
<div className="grid grid-cols-2 gap-1">
|
</span>
|
||||||
{(it.value as any[]).slice(0, 14).map((row: any) => (
|
<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
|
<div
|
||||||
key={String(row.domain)}
|
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(
|
className={clsx(
|
||||||
'text-[10px] font-bold',
|
"px-2 py-1 text-[10px] font-mono flex items-center justify-between border",
|
||||||
row.status === 'available'
|
row.status === 'available'
|
||||||
? 'text-accent'
|
? "border-accent/20 bg-accent/5 text-accent"
|
||||||
: row.status === 'taken'
|
: "border-white/5 bg-white/[0.02] text-white/40"
|
||||||
? 'text-white/40'
|
|
||||||
: 'text-amber-300'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{String(row.status).toUpperCase()}
|
<span className="truncate">{String(row.domain)}</span>
|
||||||
</span>
|
{row.status === 'available' && <Check className="w-2.5 h-2.5 shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
formatValue(it.value)
|
<div className={clsx(
|
||||||
)}
|
"text-xs font-mono",
|
||||||
</div>
|
item.status === 'pass' ? "text-white/60" :
|
||||||
{it.details && Object.keys(it.details).length ? (
|
item.status === 'warn' ? "text-amber-300/80" :
|
||||||
<details className="mt-2">
|
item.status === 'fail' ? "text-red-300/80" : "text-white/40"
|
||||||
<summary className="text-[10px] font-mono text-white/30 cursor-pointer hover:text-white/50">
|
)}>
|
||||||
Details
|
{formatValue(item.value)}
|
||||||
</summary>
|
|
||||||
<pre className="mt-2 text-[10px] leading-relaxed font-mono text-white/30 overflow-x-auto bg-black/30 border border-white/10 p-2">
|
|
||||||
{JSON.stringify(it.details, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
|
||||||
<span className={clsx('text-[9px] font-bold uppercase tracking-wider px-2 py-1 border', statusPill(it.status))}>
|
|
||||||
{it.status}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] font-mono text-white/25">{it.source}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Details Toggle */}
|
||||||
<div className="px-4 py-3 border-t border-white/[0.08] bg-white/[0.02]">
|
{item.details && Object.keys(item.details).length > 0 && (
|
||||||
<div className="text-[10px] font-mono text-white/30">
|
<details className="mt-2">
|
||||||
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.
|
<summary className="text-[10px] font-mono text-white/25 cursor-pointer hover:text-white/40 select-none">
|
||||||
|
View details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1.5 text-[9px] font-mono text-white/30 bg-black/40 border border-white/5 p-2 overflow-x-auto rounded">
|
||||||
|
{JSON.stringify(item.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -350,55 +350,6 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info Cards */}
|
|
||||||
{items.length === 0 && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-accent/20 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
|
||||||
<Wand2 className="w-5 h-5 text-accent" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white">AI-Powered</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40">Smart generation</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/30 leading-relaxed">
|
|
||||||
Generate pronounceable, memorable names following proven patterns like CVCVC.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
|
|
||||||
<Zap className="w-5 h-5 text-white/40" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white">Verified</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40">Real availability</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/30 leading-relaxed">
|
|
||||||
Every domain is checked via DNS/RDAP. Only verified available domains are shown.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
|
|
||||||
<ExternalLink className="w-5 h-5 text-white/40" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white">Instant Register</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40">One-click buy</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/30 leading-relaxed">
|
|
||||||
Found a perfect name? Register instantly via Namecheap with one click.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,10 @@ import { api } from '@/lib/api'
|
|||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import {
|
import {
|
||||||
Download,
|
|
||||||
Clock,
|
Clock,
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@ -21,7 +19,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Zap,
|
Zap,
|
||||||
Calendar,
|
Filter,
|
||||||
Ban,
|
Ban,
|
||||||
Hash,
|
Hash,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@ -43,9 +41,23 @@ interface DroppedDomain {
|
|||||||
interface ZoneStats {
|
interface ZoneStats {
|
||||||
ch: { domain_count: number; last_sync: string | null }
|
ch: { domain_count: number; last_sync: string | null }
|
||||||
li: { domain_count: number; last_sync: string | null }
|
li: { domain_count: number; last_sync: string | null }
|
||||||
weekly_drops: number
|
daily_drops: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All supported TLDs
|
||||||
|
type SupportedTld = 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
|
||||||
|
|
||||||
|
const ALL_TLDS: { tld: SupportedTld; flag: string }[] = [
|
||||||
|
{ tld: 'ch', flag: '🇨🇭' },
|
||||||
|
{ tld: 'li', flag: '🇱🇮' },
|
||||||
|
{ tld: 'xyz', flag: '🌐' },
|
||||||
|
{ tld: 'org', flag: '🏛️' },
|
||||||
|
{ tld: 'online', flag: '💻' },
|
||||||
|
{ tld: 'info', flag: 'ℹ️' },
|
||||||
|
{ tld: 'dev', flag: '👨💻' },
|
||||||
|
{ tld: 'app', flag: '📱' },
|
||||||
|
]
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// COMPONENT
|
// COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -65,14 +77,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
// All supported TLDs
|
|
||||||
type SupportedTld = 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
|
|
||||||
|
|
||||||
// Filter State
|
// Filter State
|
||||||
const [selectedTld, setSelectedTld] = useState<SupportedTld | null>(null)
|
const [selectedTld, setSelectedTld] = useState<SupportedTld | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [searchFocused, setSearchFocused] = useState(false)
|
const [searchFocused, setSearchFocused] = useState(false)
|
||||||
const [days, setDays] = useState(7)
|
|
||||||
const [minLength, setMinLength] = useState<number | undefined>(undefined)
|
const [minLength, setMinLength] = useState<number | undefined>(undefined)
|
||||||
const [maxLength, setMaxLength] = useState<number | undefined>(undefined)
|
const [maxLength, setMaxLength] = useState<number | undefined>(undefined)
|
||||||
const [excludeNumeric, setExcludeNumeric] = useState(true)
|
const [excludeNumeric, setExcludeNumeric] = useState(true)
|
||||||
@ -84,8 +92,8 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
const ITEMS_PER_PAGE = 50
|
const ITEMS_PER_PAGE = 50
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('date')
|
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('length')
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
// Tracking
|
// Tracking
|
||||||
const [tracking, setTracking] = useState<string | null>(null)
|
const [tracking, setTracking] = useState<string | null>(null)
|
||||||
@ -100,7 +108,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load Drops
|
// Load Drops (only last 24h)
|
||||||
const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => {
|
const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => {
|
||||||
if (isRefresh) setRefreshing(true)
|
if (isRefresh) setRefreshing(true)
|
||||||
else setLoading(true)
|
else setLoading(true)
|
||||||
@ -108,7 +116,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.getDrops({
|
const result = await api.getDrops({
|
||||||
tld: selectedTld || undefined,
|
tld: selectedTld || undefined,
|
||||||
days,
|
hours: 24, // Only last 24h - fresh drops only!
|
||||||
min_length: minLength,
|
min_length: minLength,
|
||||||
max_length: maxLength,
|
max_length: maxLength,
|
||||||
exclude_numeric: excludeNumeric,
|
exclude_numeric: excludeNumeric,
|
||||||
@ -121,7 +129,6 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
setTotal(result.total)
|
setTotal(result.total)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load drops:', error)
|
console.error('Failed to load drops:', error)
|
||||||
// If API returns error, show info message
|
|
||||||
if (error.message?.includes('401') || error.message?.includes('auth')) {
|
if (error.message?.includes('401') || error.message?.includes('auth')) {
|
||||||
showToast('Login required to view drops', 'info')
|
showToast('Login required to view drops', 'info')
|
||||||
}
|
}
|
||||||
@ -131,7 +138,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [selectedTld, days, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
|
}, [selectedTld, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
|
||||||
|
|
||||||
// Initial Load
|
// Initial Load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -199,12 +206,15 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
maxLength !== undefined,
|
maxLength !== undefined,
|
||||||
excludeNumeric,
|
excludeNumeric,
|
||||||
excludeHyphen,
|
excludeHyphen,
|
||||||
days !== 7
|
|
||||||
].filter(Boolean).length
|
].filter(Boolean).length
|
||||||
|
|
||||||
const formatDate = (iso: string) => {
|
const formatTime = (iso: string) => {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
return d.toLocaleDateString('de-CH', { day: '2-digit', month: 'short' })
|
const now = new Date()
|
||||||
|
const diffH = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60))
|
||||||
|
if (diffH < 1) return 'Just now'
|
||||||
|
if (diffH === 1) return '1h ago'
|
||||||
|
return `${diffH}h ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && items.length === 0) {
|
if (loading && items.length === 0) {
|
||||||
@ -217,66 +227,28 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Stats Cards */}
|
{/* Header Stats */}
|
||||||
{stats && (
|
<div className="flex items-center justify-between">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="border border-accent/20 bg-accent/[0.03] p-3">
|
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<Zap className="w-5 h-5 text-accent" />
|
||||||
<Zap className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-[10px] font-mono text-accent/60 uppercase">Weekly Drops</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold text-accent font-mono">
|
<div>
|
||||||
{stats.weekly_drops.toLocaleString()}
|
<div className="text-xl font-bold text-white font-mono">
|
||||||
|
{stats?.daily_drops?.toLocaleString() || total.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-mono text-white/30">
|
<div className="text-[10px] font-mono text-white/40 uppercase">Fresh drops (24h)</div>
|
||||||
Last 7 days
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search & Filters */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"relative border transition-all duration-200",
|
"relative border transition-all duration-200",
|
||||||
@ -290,7 +262,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onFocus={() => setSearchFocused(true)}
|
onFocus={() => setSearchFocused(true)}
|
||||||
onBlur={() => setSearchFocused(false)}
|
onBlur={() => setSearchFocused(false)}
|
||||||
placeholder="Search dropped domains..."
|
placeholder="Search drops..."
|
||||||
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@ -298,59 +270,30 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TLD Quick Filter */}
|
{/* TLD Quick Filter */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedTld(null)}
|
onClick={() => setSelectedTld(null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
|
"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"
|
selectedTld === null ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
{/* Switch.ch TLDs */}
|
{ALL_TLDS.map(({ tld, flag }) => (
|
||||||
{[
|
|
||||||
{ tld: 'ch', flag: '🇨🇭' },
|
|
||||||
{ tld: 'li', flag: '🇱🇮' },
|
|
||||||
].map(({ tld, flag }) => (
|
|
||||||
<button
|
<button
|
||||||
key={tld}
|
key={tld}
|
||||||
onClick={() => setSelectedTld(tld as 'ch' | 'li')}
|
onClick={() => setSelectedTld(tld)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors flex items-center gap-1.5",
|
"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"
|
selectedTld === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{flag}</span> .{tld}
|
<span className="text-xs">{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -365,7 +308,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4 text-white/40" />
|
<Filter className="w-4 h-4 text-white/40" />
|
||||||
<span className="text-xs font-mono text-white/60">Advanced Filters</span>
|
<span className="text-xs font-mono text-white/60">Filters</span>
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>
|
<span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>
|
||||||
)}
|
)}
|
||||||
@ -376,40 +319,16 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
{/* Filters Panel */}
|
{/* Filters Panel */}
|
||||||
{filtersOpen && (
|
{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">
|
<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 */}
|
{/* Length Filter */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Domain Length</div>
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Length</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={minLength || ''}
|
value={minLength || ''}
|
||||||
onChange={(e) => setMinLength(e.target.value ? Number(e.target.value) : undefined)}
|
onChange={(e) => setMinLength(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
placeholder="Min"
|
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"
|
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}
|
min={1}
|
||||||
max={63}
|
max={63}
|
||||||
/>
|
/>
|
||||||
@ -419,11 +338,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
value={maxLength || ''}
|
value={maxLength || ''}
|
||||||
onChange={(e) => setMaxLength(e.target.value ? Number(e.target.value) : undefined)}
|
onChange={(e) => setMaxLength(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
placeholder="Max"
|
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"
|
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}
|
min={1}
|
||||||
max={63}
|
max={63}
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] font-mono text-white/30">characters</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -437,11 +355,11 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Hash className="w-4 h-4 text-white/40" />
|
<Hash className="w-3.5 h-3.5 text-white/40" />
|
||||||
<span className="text-xs font-mono text-white/60">Exclude numeric</span>
|
<span className="text-[10px] font-mono text-white/60">No numbers</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx("w-4 h-4 border flex items-center justify-center", excludeNumeric ? "border-accent bg-accent" : "border-white/30")}>
|
<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-[10px] font-bold">✓</span>}
|
{excludeNumeric && <span className="text-black text-[8px] font-bold">✓</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -453,50 +371,45 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Ban className="w-4 h-4 text-white/40" />
|
<Ban className="w-3.5 h-3.5 text-white/40" />
|
||||||
<span className="text-xs font-mono text-white/60">Exclude hyphens</span>
|
<span className="text-[10px] font-mono text-white/60">No hyphens</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx("w-4 h-4 border flex items-center justify-center", excludeHyphen ? "border-accent bg-accent" : "border-white/30")}>
|
<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-[10px] font-bold">✓</span>}
|
{excludeHyphen && <span className="text-black text-[8px] font-bold">✓</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Bar */}
|
{/* Stats Bar */}
|
||||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
|
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
|
||||||
<span>{total.toLocaleString()} dropped domains found</span>
|
<span>{total.toLocaleString()} fresh drops</span>
|
||||||
<span>Page {page}/{Math.max(1, totalPages)}</span>
|
{totalPages > 1 && <span>Page {page}/{totalPages}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{sortedItems.length === 0 ? (
|
{sortedItems.length === 0 ? (
|
||||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||||
<Globe className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
<Globe className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||||
<p className="text-white/40 text-sm font-mono">No dropped domains found</p>
|
<p className="text-white/40 text-sm font-mono">No fresh drops</p>
|
||||||
<p className="text-white/25 text-xs font-mono mt-1">
|
<p className="text-white/25 text-xs font-mono mt-1">Check back after the next sync</p>
|
||||||
{stats?.weekly_drops === 0
|
|
||||||
? 'Zone file sync may be pending'
|
|
||||||
: 'Try adjusting your filters'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||||
{/* Desktop Table Header */}
|
{/* Desktop Table Header */}
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
<div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||||
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
||||||
Domain
|
Domain
|
||||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleSort('length')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
<button onClick={() => handleSort('length')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||||
Length
|
Len
|
||||||
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleSort('date')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
<button onClick={() => handleSort('date')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||||
Dropped
|
When
|
||||||
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-right">Actions</div>
|
<div className="text-right">Actions</div>
|
||||||
@ -506,21 +419,21 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
<div key={item.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
|
<div key={item.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
|
||||||
{/* Mobile Row */}
|
{/* Mobile Row */}
|
||||||
<div className="lg:hidden p-3">
|
<div className="lg:hidden p-3">
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-center justify-between gap-3 mb-2">
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div className="w-8 h-8 flex items-center justify-center border bg-white/[0.02] border-white/[0.06] shrink-0">
|
<span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
|
||||||
<span className="text-sm">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
|
|
||||||
</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">
|
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
|
||||||
{item.domain}
|
{item.domain}
|
||||||
</button>
|
</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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -528,34 +441,29 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => track(item.domain)}
|
onClick={() => track(item.domain)}
|
||||||
disabled={tracking === item.domain}
|
disabled={tracking === item.domain}
|
||||||
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5 transition-all"
|
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5"
|
||||||
>
|
>
|
||||||
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||||||
Track
|
Track
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 border border-white/[0.08] text-white/50 flex items-center justify-center">
|
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 border border-white/[0.08] text-white/50 flex items-center justify-center">
|
||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`https://www.nic.${item.tld}/whois/?domain=${item.domain.split('.')[0]}`}
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${item.domain}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5"
|
className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
Register
|
Get <ExternalLink className="w-3 h-3" />
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Row */}
|
{/* Desktop Row */}
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 items-center p-3 group">
|
<div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 items-center p-3 group">
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div className="w-8 h-8 flex items-center justify-center border bg-white/[0.02] border-white/[0.06] shrink-0">
|
<span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
|
||||||
<span className="text-sm">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openAnalyze(item.domain)}
|
onClick={() => openAnalyze(item.domain)}
|
||||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||||
@ -566,7 +474,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-xs font-mono font-bold px-2 py-0.5",
|
"text-[10px] font-mono font-bold px-1.5 py-0.5",
|
||||||
item.length <= 5 ? "text-accent bg-accent/10" : item.length <= 8 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5"
|
item.length <= 5 ? "text-accent bg-accent/10" : item.length <= 8 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5"
|
||||||
)}>
|
)}>
|
||||||
{item.length}
|
{item.length}
|
||||||
@ -574,33 +482,30 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="text-xs font-mono text-white/50">{formatDate(item.dropped_date)}</span>
|
<span className="text-[10px] font-mono text-white/50">{formatTime(item.dropped_date)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-end gap-1.5 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => track(item.domain)}
|
onClick={() => track(item.domain)}
|
||||||
disabled={tracking === item.domain}
|
disabled={tracking === item.domain}
|
||||||
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
|
className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
|
||||||
>
|
>
|
||||||
{tracking === item.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
|
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openAnalyze(item.domain)}
|
onClick={() => openAnalyze(item.domain)}
|
||||||
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors"
|
className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
|
||||||
>
|
>
|
||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`https://www.nic.${item.tld}/whois/?domain=${item.domain.split('.')[0]}`}
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${item.domain}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="h-7 px-3 bg-accent text-black text-xs font-bold flex items-center gap-1 hover:bg-white transition-colors"
|
className="h-6 px-2 bg-accent text-black text-[10px] font-bold flex items-center gap-1 hover:bg-white"
|
||||||
>
|
>
|
||||||
Get
|
Get
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -610,60 +515,26 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-center gap-1 pt-2">
|
||||||
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider">
|
|
||||||
Page {page}/{totalPages}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(page - 1)}
|
onClick={() => handlePageChange(page - 1)}
|
||||||
disabled={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"
|
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" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-xs text-white/50 font-mono px-3">{page}/{totalPages}</span>
|
||||||
<span className="text-xs text-white/50 font-mono px-2">
|
|
||||||
{page}/{totalPages}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(page + 1)}
|
onClick={() => handlePageChange(page + 1)}
|
||||||
disabled={page === totalPages}
|
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"
|
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" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
|
|
||||||
<Download className="w-5 h-5 text-accent" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-white mb-1">Zone File Analysis</h3>
|
|
||||||
<p className="text-xs text-white/40 leading-relaxed mb-2">
|
|
||||||
Domains are detected by comparing daily zone file snapshots. Data sources:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[10px] font-mono">
|
|
||||||
<div className="flex items-center gap-2 text-white/50">
|
|
||||||
<span>🇨🇭</span>
|
|
||||||
<span><strong>.ch/.li</strong> via Switch.ch (AXFR)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-white/50">
|
|
||||||
<span>🌐</span>
|
|
||||||
<span><strong>gTLDs</strong> via ICANN CZDS</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -331,55 +331,6 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Tips */}
|
|
||||||
{!searchResult && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-accent/20 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center group-hover:bg-accent/20 transition-colors">
|
|
||||||
<Globe className="w-5 h-5 text-accent" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white">Instant Check</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40">RDAP/WHOIS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/30 leading-relaxed">
|
|
||||||
Check any domain's availability in real-time using RDAP/WHOIS protocols.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:bg-white/[0.05] transition-colors">
|
|
||||||
<Eye className="w-5 h-5 text-white/40" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white">Monitor Drops</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40">Watchlist</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/30 leading-relaxed">
|
|
||||||
Add taken domains to your watchlist. Get alerted when they become available.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:bg-white/[0.05] transition-colors">
|
|
||||||
<Shield className="w-5 h-5 text-white/40" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white">Deep Analysis</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40">Full report</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/30 leading-relaxed">
|
|
||||||
Run full analysis: backlinks, SEO metrics, history, trademark checks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1839,13 +1839,13 @@ class AdminApiClient extends ApiClient {
|
|||||||
return this.request<{
|
return this.request<{
|
||||||
ch: { domain_count: number; last_sync: string | null }
|
ch: { domain_count: number; last_sync: string | null }
|
||||||
li: { domain_count: number; last_sync: string | null }
|
li: { domain_count: number; last_sync: string | null }
|
||||||
weekly_drops: number
|
daily_drops: number
|
||||||
}>('/drops/stats')
|
}>('/drops/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDrops(params?: {
|
async getDrops(params?: {
|
||||||
tld?: 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
|
tld?: 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
|
||||||
days?: number
|
hours?: number
|
||||||
min_length?: number
|
min_length?: number
|
||||||
max_length?: number
|
max_length?: number
|
||||||
exclude_numeric?: boolean
|
exclude_numeric?: boolean
|
||||||
@ -1856,7 +1856,7 @@ class AdminApiClient extends ApiClient {
|
|||||||
}) {
|
}) {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params?.tld) query.set('tld', params.tld)
|
if (params?.tld) query.set('tld', params.tld)
|
||||||
if (params?.days) query.set('days', params.days.toString())
|
if (params?.hours) query.set('hours', params.hours.toString())
|
||||||
if (params?.min_length) query.set('min_length', params.min_length.toString())
|
if (params?.min_length) query.set('min_length', params.min_length.toString())
|
||||||
if (params?.max_length) query.set('max_length', params.max_length.toString())
|
if (params?.max_length) query.set('max_length', params.max_length.toString())
|
||||||
if (params?.exclude_numeric) query.set('exclude_numeric', 'true')
|
if (params?.exclude_numeric) query.set('exclude_numeric', 'true')
|
||||||
|
|||||||
Reference in New Issue
Block a user