From 96abe9945ee316d3ab7487a23a5b52de5c86d413 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 08:02:33 +0000 Subject: [PATCH 01/17] feat: implement handle lookup core business logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 40+ platform configs (platforms.ts) using Sherlock's detection approach — status code, message match, and response URL patterns - Add parallel checker (lookup.ts) with per-platform timeout and SSE streaming callback - Add /api/lookup SSE route that streams results as each platform check completes - Add /lookup/[handle] results page with progressive card rendering and skeleton loading - Add LookupForm client component wired to the hero search bar - Wire hero.tsx to use the functional LookupForm https://claude.ai/code/session_011nbTf3SpFDiLh1LkHfrxow --- src/app/api/lookup/route.ts | 51 +++++ src/app/lookup/[handle]/page.tsx | 43 ++++ src/components/hero.tsx | 13 +- src/components/lookup-form.tsx | 36 ++++ src/components/lookup-results.tsx | 231 +++++++++++++++++++++ src/lib/lookup.ts | 132 ++++++++++++ src/lib/platforms.ts | 323 ++++++++++++++++++++++++++++++ 7 files changed, 818 insertions(+), 11 deletions(-) create mode 100644 src/app/api/lookup/route.ts create mode 100644 src/app/lookup/[handle]/page.tsx create mode 100644 src/components/lookup-form.tsx create mode 100644 src/components/lookup-results.tsx create mode 100644 src/lib/lookup.ts create mode 100644 src/lib/platforms.ts diff --git a/src/app/api/lookup/route.ts b/src/app/api/lookup/route.ts new file mode 100644 index 0000000..1e0d0ef --- /dev/null +++ b/src/app/api/lookup/route.ts @@ -0,0 +1,51 @@ +import { type NextRequest } from "next/server"; +import { checkAllPlatforms } from "@/lib/lookup"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HANDLE_REGEX = /^[a-zA-Z0-9_.-]{1,50}$/; + +export async function GET(request: NextRequest) { + const handle = request.nextUrl.searchParams.get("handle")?.trim(); + + if (!handle || !HANDLE_REGEX.test(handle)) { + return Response.json({ error: "Invalid handle" }, { status: 400 }); + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + try { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(data)}\n\n`) + ); + } catch { + // Client disconnected + } + }; + + try { + await checkAllPlatforms(handle, send); + } finally { + try { + send({ done: true }); + controller.close(); + } catch { + // Already closed + } + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/src/app/lookup/[handle]/page.tsx b/src/app/lookup/[handle]/page.tsx new file mode 100644 index 0000000..e45b312 --- /dev/null +++ b/src/app/lookup/[handle]/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Header } from "@/components/header"; +import { Footer } from "@/components/footer"; +import { LookupResults } from "@/components/lookup-results"; +import { cn } from "@/lib/utils"; + +type Props = { + params: Promise<{ handle: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { handle } = await params; + return { + title: `@${handle} — Handle Lookup`, + description: `Check if @${handle} is available across GitHub, Instagram, TikTok, Twitter, and 40+ platforms.`, + robots: { index: false }, + }; +} + +export default async function LookupPage({ params }: Props) { + const { handle } = await params; + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/hero.tsx b/src/components/hero.tsx index bb477f0..e2fb42e 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -1,9 +1,8 @@ import { cn } from "@/lib/utils"; import { DecorIcon } from "@/components/ui/decor-icon"; import { FullWidthDivider } from "@/components/ui/full-width-divider"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { PlatformsGrid } from "@/components/platforms-grid"; +import { LookupForm } from "@/components/lookup-form"; export function HeroSection() { return ( @@ -45,15 +44,7 @@ export function HeroSection() { so you can claim the right handle faster.

-
- - -
+
diff --git a/src/components/lookup-form.tsx b/src/components/lookup-form.tsx new file mode 100644 index 0000000..85c1aaa --- /dev/null +++ b/src/components/lookup-form.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +export function LookupForm() { + const [handle, setHandle] = useState(""); + const router = useRouter(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = handle.trim().replace(/^@/, ""); + if (trimmed) { + router.push(`/lookup/${encodeURIComponent(trimmed)}`); + } + }; + + return ( +
+ setHandle(e.target.value)} + /> + +
+ ); +} diff --git a/src/components/lookup-results.tsx b/src/components/lookup-results.tsx new file mode 100644 index 0000000..589e35a --- /dev/null +++ b/src/components/lookup-results.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { + ExternalLink, + Search, + CheckCircle, + XCircle, + HelpCircle, + Loader2, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + CardAction, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useRouter } from "next/navigation"; +import type { PlatformResult } from "@/lib/lookup"; + +type ResultWithDone = PlatformResult & { done?: boolean }; + +const STATUS_CONFIG = { + available: { + label: "Available", + variant: "default" as const, + icon: CheckCircle, + className: + "bg-green-500/10 text-green-600 border-green-500/20 dark:text-green-400", + }, + taken: { + label: "Taken", + variant: "destructive" as const, + icon: XCircle, + className: + "bg-red-500/10 text-red-600 border-red-500/20 dark:text-red-400", + }, + unknown: { + label: "Unknown", + variant: "outline" as const, + icon: HelpCircle, + className: "text-muted-foreground", + }, +}; + +function ResultCard({ + result, + handle, +}: { + result: PlatformResult; + handle: string; +}) { + const config = STATUS_CONFIG[result.status]; + const Icon = config.icon; + const displayUrl = result.url.replace(/\{\}/g, handle); + + return ( + + + {result.platform} + + + + {config.label} + + + + +

+ Handle: @{handle} +

+ {result.responseTime > 0 && ( +

+ {result.responseTime}ms +

+ )} +
+ + + +
+ ); +} + +function SkeletonCard() { + return ( + + +
+
+ + +
+ + +
+ + + ); +} + +export function LookupResults({ handle }: { handle: string }) { + const router = useRouter(); + const [results, setResults] = useState([]); + const [done, setDone] = useState(false); + const [searchInput, setSearchInput] = useState(handle); + const [totalPlatforms, setTotalPlatforms] = useState(0); + + const fetchResults = useCallback(() => { + setResults([]); + setDone(false); + + const eventSource = new EventSource( + `/api/lookup?handle=${encodeURIComponent(handle)}` + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data as string) as ResultWithDone; + if (data.done) { + setDone(true); + eventSource.close(); + return; + } + setResults((prev) => [...prev, data as PlatformResult]); + } catch { + // ignore parse errors + } + }; + + eventSource.onerror = () => { + setDone(true); + eventSource.close(); + }; + + return () => eventSource.close(); + }, [handle]); + + useEffect(() => { + // Fetch total platforms count + import("@/lib/lookup").then(({ PLATFORMS }) => { + setTotalPlatforms(PLATFORMS.length); + }); + return fetchResults(); + }, [fetchResults]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = searchInput.trim().replace(/^@/, ""); + if (trimmed && trimmed !== handle) { + router.push(`/lookup/${encodeURIComponent(trimmed)}`); + } + }; + + const available = results.filter((r) => r.status === "available").length; + const taken = results.filter((r) => r.status === "taken").length; + + return ( +
+ {/* Search bar */} +
+ setSearchInput(e.target.value)} + placeholder="Try another handle…" + className="h-9 max-w-xs" + /> + +
+ + {/* Header */} +
+

+ Results for @{handle} +

+
+ {!done ? ( + <> + + + Checking {results.length} /{" "} + {totalPlatforms || "…"} platforms… + + + ) : ( + + Checked {results.length} platforms —{" "} + + {available} available + + {", "} + + {taken} taken + + + )} +
+
+ + {/* Results grid */} +
+ {results.map((result) => ( + + ))} + {/* Skeleton placeholders while loading */} + {!done && + Array.from({ + length: Math.max(0, (totalPlatforms || 6) - results.length), + }).map((_, i) => )} +
+
+ ); +} diff --git a/src/lib/lookup.ts b/src/lib/lookup.ts new file mode 100644 index 0000000..d145456 --- /dev/null +++ b/src/lib/lookup.ts @@ -0,0 +1,132 @@ +import { PLATFORMS, type Platform } from "./platforms"; + +export type LookupStatus = "available" | "taken" | "unknown"; + +export interface PlatformResult { + platform: string; + category: string; + url: string; + status: LookupStatus; + responseTime: number; +} + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const TIMEOUT_MS = 10_000; + +async function checkPlatform( + platform: Platform, + handle: string +): Promise { + const url = platform.url.replace(/\{\}/g, encodeURIComponent(handle)); + const profileUrl = platform.url.replace(/\{\}/g, handle); // unencoded for display + const start = Date.now(); + + // Validate username format if regex provided + if (platform.usernameRegex) { + const regex = new RegExp(platform.usernameRegex); + if (!regex.test(handle)) { + return { + platform: platform.name, + category: platform.category, + url: profileUrl, + status: "unknown", + responseTime: 0, + }; + } + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + const response = await fetch(url, { + method: "GET", + headers: { + "User-Agent": USER_AGENT, + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + ...platform.requestHeaders, + }, + redirect: "follow", + signal: controller.signal, + }); + + clearTimeout(timeout); + const responseTime = Date.now() - start; + + switch (platform.errorType) { + case "status_code": { + const notFoundCode = platform.errorCode ?? 404; + return { + platform: platform.name, + category: platform.category, + url: profileUrl, + status: response.status === notFoundCode ? "available" : "taken", + responseTime, + }; + } + + case "message": { + const body = await response.text(); + const errorStrings = platform.errorMsg ?? []; + const isNotFound = errorStrings.some((msg) => + body.toLowerCase().includes(msg.toLowerCase()) + ); + return { + platform: platform.name, + category: platform.category, + url: profileUrl, + status: isNotFound ? "available" : "taken", + responseTime, + }; + } + + case "response_url": { + const errorUrl = platform.errorUrl ?? ""; + return { + platform: platform.name, + category: platform.category, + url: profileUrl, + status: response.url.includes(errorUrl) ? "available" : "taken", + responseTime, + }; + } + + default: + return { + platform: platform.name, + category: platform.category, + url: profileUrl, + status: "unknown", + responseTime: Date.now() - start, + }; + } + } catch { + return { + platform: platform.name, + category: platform.category, + url: profileUrl, + status: "unknown", + responseTime: Date.now() - start, + }; + } +} + +/** + * Check all platforms for a handle. Calls onResult as each check completes. + */ +export async function checkAllPlatforms( + handle: string, + onResult: (result: PlatformResult) => void +): Promise { + await Promise.allSettled( + PLATFORMS.map(async (platform) => { + const result = await checkPlatform(platform, handle); + onResult(result); + }) + ); +} + +export { PLATFORMS }; diff --git a/src/lib/platforms.ts b/src/lib/platforms.ts new file mode 100644 index 0000000..661250b --- /dev/null +++ b/src/lib/platforms.ts @@ -0,0 +1,323 @@ +export type ErrorType = "status_code" | "message" | "response_url"; +export type Category = + | "social" + | "developer" + | "gaming" + | "creative" + | "music" + | "writing" + | "professional"; + +export interface Platform { + name: string; + category: Category; + url: string; // Profile URL template, {} = username + urlMain: string; // Platform homepage + errorType: ErrorType; + errorCode?: number; // Status code = user NOT found (default 404 for status_code type) + errorMsg?: string[]; // Body strings = user NOT found (for message type) + errorUrl?: string; // Response URL fragment = user NOT found (for response_url type) + requestHeaders?: Record; + usernameRegex?: string; // Optional: regex to validate username format before checking +} + +export const PLATFORMS: Platform[] = [ + // === DEVELOPER === + { name: "GitHub", category: "developer", url: "https://github.com/{}", urlMain: "https://github.com", errorType: "status_code" }, + { name: "GitLab", category: "developer", url: "https://gitlab.com/{}", urlMain: "https://gitlab.com", errorType: "status_code" }, + { name: "Bitbucket", category: "developer", url: "https://bitbucket.org/{}", urlMain: "https://bitbucket.org", errorType: "status_code" }, + { name: "npm", category: "developer", url: "https://www.npmjs.com/~{}", urlMain: "https://www.npmjs.com", errorType: "status_code" }, + { name: "PyPI", category: "developer", url: "https://pypi.org/user/{}/", urlMain: "https://pypi.org", errorType: "status_code" }, + { name: "Dev.to", category: "developer", url: "https://dev.to/{}", urlMain: "https://dev.to", errorType: "status_code" }, + { name: "HackerNews", category: "developer", url: "https://hn.algolia.com/api/v1/users/{}", urlMain: "https://news.ycombinator.com", errorType: "status_code" }, + { name: "CodePen", category: "developer", url: "https://codepen.io/{}", urlMain: "https://codepen.io", errorType: "status_code" }, + { name: "itch.io", category: "developer", url: "https://{}.itch.io", urlMain: "https://itch.io", errorType: "status_code" }, + + // === SOCIAL === + { + name: "Reddit", + category: "social", + url: "https://www.reddit.com/user/{}", + urlMain: "https://www.reddit.com", + errorType: "status_code", + }, + { + name: "X (Twitter)", + category: "social", + url: "https://x.com/{}", + urlMain: "https://x.com", + errorType: "status_code", + requestHeaders: { "Accept-Language": "en-US,en;q=0.9" }, + }, + { + name: "Instagram", + category: "social", + url: "https://www.instagram.com/{}/", + urlMain: "https://www.instagram.com", + errorType: "status_code", + }, + { + name: "TikTok", + category: "social", + url: "https://www.tiktok.com/@{}", + urlMain: "https://www.tiktok.com", + errorType: "status_code", + }, + { + name: "Pinterest", + category: "social", + url: "https://www.pinterest.com/{}/", + urlMain: "https://www.pinterest.com", + errorType: "status_code", + }, + { + name: "Tumblr", + category: "social", + url: "https://{}.tumblr.com/", + urlMain: "https://www.tumblr.com", + errorType: "message", + errorMsg: ["There's nothing here.", "Not found.", "Whatever you were looking for doesn't live here"], + }, + { + name: "Snapchat", + category: "social", + url: "https://www.snapchat.com/add/{}", + urlMain: "https://www.snapchat.com", + errorType: "message", + errorMsg: ["Sorry, we couldn't find", "Sorry, we can't find"], + }, + { + name: "Telegram", + category: "social", + url: "https://t.me/{}", + urlMain: "https://telegram.org", + errorType: "message", + errorMsg: ["If you have Telegram, you can contact", "Sorry, this username doesn't exist"], + usernameRegex: "^[a-zA-Z][a-zA-Z0-9_]{4,}$", + }, + { + name: "Mastodon", + category: "social", + url: "https://mastodon.social/api/v1/accounts/lookup?acct={}", + urlMain: "https://mastodon.social", + errorType: "status_code", + }, + { + name: "Bluesky", + category: "social", + url: "https://bsky.app/profile/{}", + urlMain: "https://bsky.app", + errorType: "status_code", + }, + + // === VIDEO/STREAMING === + { + name: "YouTube", + category: "social", + url: "https://www.youtube.com/@{}", + urlMain: "https://www.youtube.com", + errorType: "status_code", + }, + { + name: "Twitch", + category: "gaming", + url: "https://www.twitch.tv/{}", + urlMain: "https://www.twitch.tv", + errorType: "status_code", + }, + { + name: "Vimeo", + category: "social", + url: "https://vimeo.com/{}", + urlMain: "https://vimeo.com", + errorType: "status_code", + }, + + // === MUSIC === + { + name: "SoundCloud", + category: "music", + url: "https://soundcloud.com/{}", + urlMain: "https://soundcloud.com", + errorType: "status_code", + }, + { + name: "Last.fm", + category: "music", + url: "https://www.last.fm/user/{}", + urlMain: "https://www.last.fm", + errorType: "status_code", + }, + { + name: "Bandcamp", + category: "music", + url: "https://{}.bandcamp.com/", + urlMain: "https://bandcamp.com", + errorType: "status_code", + }, + + // === CREATIVE === + { + name: "Behance", + category: "creative", + url: "https://www.behance.net/{}", + urlMain: "https://www.behance.net", + errorType: "status_code", + }, + { + name: "Dribbble", + category: "creative", + url: "https://dribbble.com/{}", + urlMain: "https://dribbble.com", + errorType: "status_code", + }, + { + name: "DeviantArt", + category: "creative", + url: "https://www.deviantart.com/{}", + urlMain: "https://www.deviantart.com", + errorType: "status_code", + }, + { + name: "Flickr", + category: "creative", + url: "https://www.flickr.com/people/{}/", + urlMain: "https://www.flickr.com", + errorType: "message", + errorMsg: ["Oops! We couldn't find that page.", "page not found"], + }, + { + name: "Redbubble", + category: "creative", + url: "https://www.redbubble.com/people/{}/shop", + urlMain: "https://www.redbubble.com", + errorType: "status_code", + }, + { + name: "ArtStation", + category: "creative", + url: "https://www.artstation.com/{}", + urlMain: "https://www.artstation.com", + errorType: "status_code", + }, + + // === WRITING/BLOGGING === + { + name: "Medium", + category: "writing", + url: "https://medium.com/@{}", + urlMain: "https://medium.com", + errorType: "status_code", + }, + { + name: "Substack", + category: "writing", + url: "https://{}.substack.com", + urlMain: "https://substack.com", + errorType: "status_code", + }, + { + name: "Wattpad", + category: "writing", + url: "https://www.wattpad.com/user/{}", + urlMain: "https://www.wattpad.com", + errorType: "status_code", + }, + + // === GAMING === + { + name: "Steam", + category: "gaming", + url: "https://steamcommunity.com/id/{}", + urlMain: "https://steamcommunity.com", + errorType: "message", + errorMsg: ["The specified profile could not be found.", "this user has not yet set up"], + }, + { + name: "Chess.com", + category: "gaming", + url: "https://www.chess.com/member/{}", + urlMain: "https://www.chess.com", + errorType: "status_code", + }, + { + name: "Lichess", + category: "gaming", + url: "https://lichess.org/@/{}/all", + urlMain: "https://lichess.org", + errorType: "status_code", + }, + { + name: "Roblox", + category: "gaming", + url: "https://www.roblox.com/user.aspx?username={}", + urlMain: "https://www.roblox.com", + errorType: "message", + errorMsg: ["Profile is not found", "page does not exist"], + }, + + // === PROFESSIONAL / OTHER === + { + name: "LinkedIn", + category: "professional", + url: "https://www.linkedin.com/in/{}/", + urlMain: "https://www.linkedin.com", + errorType: "status_code", + }, + { + name: "Keybase", + category: "professional", + url: "https://keybase.io/{}", + urlMain: "https://keybase.io", + errorType: "status_code", + }, + { + name: "Ko-fi", + category: "professional", + url: "https://ko-fi.com/{}", + urlMain: "https://ko-fi.com", + errorType: "status_code", + }, + { + name: "Patreon", + category: "professional", + url: "https://www.patreon.com/{}", + urlMain: "https://www.patreon.com", + errorType: "status_code", + }, + { + name: "Fiverr", + category: "professional", + url: "https://www.fiverr.com/{}", + urlMain: "https://www.fiverr.com", + errorType: "status_code", + }, + { + name: "Linktree", + category: "professional", + url: "https://linktr.ee/{}", + urlMain: "https://linktr.ee", + errorType: "status_code", + }, + { + name: "Duolingo", + category: "professional", + url: "https://www.duolingo.com/profile/{}", + urlMain: "https://www.duolingo.com", + errorType: "status_code", + }, + { + name: "About.me", + category: "professional", + url: "https://about.me/{}", + urlMain: "https://about.me", + errorType: "status_code", + }, + { + name: "Product Hunt", + category: "professional", + url: "https://www.producthunt.com/@{}", + urlMain: "https://www.producthunt.com", + errorType: "status_code", + }, +]; From ceba47b2ed3db99c655f8c7c233ff62472c9356f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 08:06:42 +0000 Subject: [PATCH 02/17] fix: resolve Google Fonts network error blocking production build Replace next/font/google imports with plain CSS fallbacks and enable turbopackUseSystemTlsCerts to work around the sandboxed build environment where fonts.googleapis.com is unreachable. https://claude.ai/code/session_011nbTf3SpFDiLh1LkHfrxow --- next.config.ts | 3 +++ src/app/layout.tsx | 19 ++----------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/next.config.ts b/next.config.ts index 87dd7bb..fb0b399 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,9 @@ import { createMDX } from "fumadocs-mdx/next"; const nextConfig: NextConfig = { /* config options here */ + experimental: { + turbopackUseSystemTlsCerts: true, + }, }; const withMDX = createMDX(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6ecdaa3..1699de1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,23 +1,10 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono, Inter } from "next/font/google"; import { JsonLd } from "@/components/json-ld"; import "./globals.css"; import { ClerkProvider } from "@clerk/nextjs"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { cn } from "@/lib/utils"; -const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { metadataBase: new URL("https://handlelookup.com"), title: { @@ -54,10 +41,8 @@ export default function RootLayout({ signInFallbackRedirectUrl="/app/profile" signUpFallbackRedirectUrl="/app/profile" > - - + + Date: Wed, 25 Mar 2026 08:28:34 +0000 Subject: [PATCH 03/17] Overhaul handle lookup: Sherlock data, homepage SSE, category filters, Geist font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Font: Restored Geist via npm package (geist/font/sans + geist/font/mono) — fixes broken system font fallback - Platforms: Replaced manual list with all 459 non-NSFW platforms from Sherlock's data.json (status_code/message/response_url detection, urlProbe, POST support, regexCheck) - Lookup service: Added concurrency limiter (50 parallel requests) and full Sherlock compatibility (urlProbe, POST payloads, custom headers, errorCode overrides) - Homepage: All lookup UI now lives on / — form updates ?q= param via nuqs, LookupResults reads it and streams SSE - Category filter: pill tabs (All, Featured, Social, Developer, Gaming, Creative, Music, Writing) derived from live results, persisted in ?category= URL param - Results: Cards animate in (fade + slide) as each platform check completes, progress bar, skeleton placeholders, color-coded Available/Taken/Unknown badges - Default state: Shows featured platform preview grid before any search - /lookup/[handle] redirects to /?q=handle https://claude.ai/code/session_011nbTf3SpFDiLh1LkHfrxow --- package.json | 1 + pnpm-lock.yaml | 48 + src/app/globals.css | 2 +- src/app/layout.tsx | 4 +- src/app/lookup/[handle]/page.tsx | 46 +- src/app/page.tsx | 7 +- src/components/hero.tsx | 13 +- src/components/lookup-form.tsx | 24 +- src/components/lookup-results.tsx | 374 ++-- src/lib/lookup.ts | 70 +- src/lib/platforms.ts | 450 ++-- src/lib/sherlock-data.json | 3283 +++++++++++++++++++++++++++++ 12 files changed, 3782 insertions(+), 540 deletions(-) create mode 100644 src/lib/sherlock-data.json diff --git a/package.json b/package.json index 5482e2a..7b655ee 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "fumadocs-core": "^16.6.17", "fumadocs-mdx": "^14.2.10", "fumadocs-ui": "^16.6.17", + "geist": "^1.7.0", "initials": "^3.1.2", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4977e..7b563ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: fumadocs-ui: specifier: ^16.6.17 version: 16.6.17(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.17(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + geist: + specifier: ^1.7.0 + version: 1.7.0(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) initials: specifier: ^3.1.2 version: 3.1.2 @@ -621,89 +624,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -822,24 +841,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.7': resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.7': resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.7': resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.7': resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} @@ -1708,24 +1731,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -1950,41 +1977,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3004,6 +3039,11 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + geist@1.7.0: + resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} + peerDependencies: + next: '>=13.2.0' + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3539,24 +3579,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -8011,6 +8055,10 @@ snapshots: fuzzysort@3.1.0: {} + geist@1.7.0(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} diff --git a/src/app/globals.css b/src/app/globals.css index b680fd6..a7a979d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,7 +13,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); + --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); /* fumadocs-ui fd-* color tokens — mapped to app theme */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1699de1..5474dd0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { GeistSans } from "geist/font/sans"; +import { GeistMono } from "geist/font/mono"; import { JsonLd } from "@/components/json-ld"; import "./globals.css"; import { ClerkProvider } from "@clerk/nextjs"; @@ -41,7 +43,7 @@ export default function RootLayout({ signInFallbackRedirectUrl="/app/profile" signUpFallbackRedirectUrl="/app/profile" > - + ; -}; - -export async function generateMetadata({ params }: Props): Promise { +}) { const { handle } = await params; - return { - title: `@${handle} — Handle Lookup`, - description: `Check if @${handle} is available across GitHub, Instagram, TikTok, Twitter, and 40+ platforms.`, - robots: { index: false }, - }; -} - -export default async function LookupPage({ params }: Props) { - const { handle } = await params; - - return ( -
-
-
- -
-
-
- ); + redirect(`/?q=${encodeURIComponent(handle)}`); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 222f8b0..2345f02 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,9 @@ +import { Suspense } from "react"; import type { Metadata } from "next"; import { cn } from "@/lib/utils"; import { Header } from "@/components/header"; // @efferd/header-2 import { HeroSection } from "@/components/hero"; +import { LookupResults } from "@/components/lookup-results"; import { LogosSection } from "@/components/logos-section"; import { CallToAction } from "@/components/cta"; import { Footer } from "@/components/footer"; @@ -61,7 +63,10 @@ export default function Page() { "after:absolute after:-inset-y-14 after:-right-px after:w-px after:bg-border", )} > - + + + +
diff --git a/src/components/hero.tsx b/src/components/hero.tsx index e2fb42e..6f8ac52 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -1,7 +1,5 @@ import { cn } from "@/lib/utils"; -import { DecorIcon } from "@/components/ui/decor-icon"; import { FullWidthDivider } from "@/components/ui/full-width-divider"; -import { PlatformsGrid } from "@/components/platforms-grid"; import { LookupForm } from "@/components/lookup-form"; export function HeroSection() { @@ -46,16 +44,7 @@ export function HeroSection() {
-
- - - - - - - - -
+ ); } diff --git a/src/components/lookup-form.tsx b/src/components/lookup-form.tsx index 85c1aaa..dd11b0c 100644 --- a/src/components/lookup-form.tsx +++ b/src/components/lookup-form.tsx @@ -1,20 +1,19 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useQueryState } from "nuqs"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Search } from "lucide-react"; export function LookupForm() { - const [handle, setHandle] = useState(""); - const router = useRouter(); + const [q, setQ] = useQueryState("q", { defaultValue: "" }); + const [input, setInput] = useState(q ?? ""); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - const trimmed = handle.trim().replace(/^@/, ""); - if (trimmed) { - router.push(`/lookup/${encodeURIComponent(trimmed)}`); - } + const trimmed = input.trim().replace(/^@/, ""); + if (trimmed) setQ(trimmed); }; return ( @@ -24,13 +23,16 @@ export function LookupForm() { > setHandle(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} /> - + ); } diff --git a/src/components/lookup-results.tsx b/src/components/lookup-results.tsx index 589e35a..5f489a2 100644 --- a/src/components/lookup-results.tsx +++ b/src/components/lookup-results.tsx @@ -1,16 +1,15 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import Link from "next/link"; -import { cn } from "@/lib/utils"; import { ExternalLink, - Search, - CheckCircle, + CheckCircle2, XCircle, HelpCircle, Loader2, } from "lucide-react"; +import { useQueryState } from "nuqs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -21,210 +20,253 @@ import { CardTitle, CardAction, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { CATEGORIES, PLATFORMS, type Category } from "@/lib/platforms"; import type { PlatformResult } from "@/lib/lookup"; -type ResultWithDone = PlatformResult & { done?: boolean }; - -const STATUS_CONFIG = { +const STATUS = { available: { label: "Available", - variant: "default" as const, - icon: CheckCircle, - className: - "bg-green-500/10 text-green-600 border-green-500/20 dark:text-green-400", + icon: CheckCircle2, + badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", }, taken: { label: "Taken", - variant: "destructive" as const, icon: XCircle, - className: - "bg-red-500/10 text-red-600 border-red-500/20 dark:text-red-400", + badge: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400", }, unknown: { label: "Unknown", - variant: "outline" as const, icon: HelpCircle, - className: "text-muted-foreground", + badge: "text-muted-foreground", }, -}; +} as const; -function ResultCard({ - result, - handle, +function CategoryFilter({ + active, + counts, + onChange, }: { - result: PlatformResult; - handle: string; + active: string; + counts: Record; + onChange: (cat: string) => void; }) { - const config = STATUS_CONFIG[result.status]; - const Icon = config.icon; - const displayUrl = result.url.replace(/\{\}/g, handle); - return ( - - - {result.platform} - - + {CATEGORIES.map((cat) => { + const count = counts[cat.id] ?? 0; + const isActive = active === cat.id; + return ( + -
- + {cat.label} + {count > 0 && ( + + {count} + + )} + + ); + })} +
); } -function SkeletonCard() { +function ResultCard({ result, index }: { result: PlatformResult; index: number }) { + const cfg = STATUS[result.status]; + const Icon = cfg.icon; return ( - - -
-
- - -
- - -
- - +
+ + + {result.platform} + + + + {cfg.label} + + + + +

{result.url}

+
+ + + +
+
); } -export function LookupResults({ handle }: { handle: string }) { - const router = useRouter(); +function SkeletonCard({ index }: { index: number }) { + return ( +
+ + +
+
+ + +
+ + +
+ + +
+ ); +} + +function StatsBar({ results, total, done }: { results: PlatformResult[]; total: number; done: boolean }) { + const available = results.filter((r) => r.status === "available").length; + const taken = results.filter((r) => r.status === "taken").length; + return ( +
+ {!done ? ( + + + Checking {results.length} / {total} platforms + + ) : ( + {results.length} platforms checked + )} + {available > 0 && ( + {available} available + )} + {taken > 0 && ( + {taken} taken + )} +
+ ); +} + +function DefaultGrid() { + const featured = PLATFORMS.filter((p) => p.category === "featured"); + return ( +
+ {featured.map((p, i) => ( +
+ + + {p.name} + + +

{p.urlMain}

+
+ +
+ + +
+ ))} +
+ ); +} + +export function LookupResults() { + const [q] = useQueryState("q", { defaultValue: "" }); + const [category, setCategory] = useQueryState("category", { defaultValue: "featured" }); const [results, setResults] = useState([]); const [done, setDone] = useState(false); - const [searchInput, setSearchInput] = useState(handle); - const [totalPlatforms, setTotalPlatforms] = useState(0); + const esRef = useRef(null); - const fetchResults = useCallback(() => { + const startLookup = useCallback((handle: string) => { + esRef.current?.close(); setResults([]); setDone(false); - - const eventSource = new EventSource( - `/api/lookup?handle=${encodeURIComponent(handle)}` - ); - - eventSource.onmessage = (event) => { + const es = new EventSource(`/api/lookup?handle=${encodeURIComponent(handle)}`); + esRef.current = es; + es.onmessage = (event) => { try { - const data = JSON.parse(event.data as string) as ResultWithDone; - if (data.done) { - setDone(true); - eventSource.close(); - return; - } - setResults((prev) => [...prev, data as PlatformResult]); - } catch { - // ignore parse errors - } + const data = JSON.parse(event.data) as PlatformResult & { done?: boolean }; + if (data.done) { setDone(true); es.close(); return; } + setResults((prev) => [...prev, data]); + } catch { /* ignore */ } }; - - eventSource.onerror = () => { - setDone(true); - eventSource.close(); - }; - - return () => eventSource.close(); - }, [handle]); + es.onerror = () => { setDone(true); es.close(); }; + }, []); useEffect(() => { - // Fetch total platforms count - import("@/lib/lookup").then(({ PLATFORMS }) => { - setTotalPlatforms(PLATFORMS.length); - }); - return fetchResults(); - }, [fetchResults]); + if (!q) { esRef.current?.close(); setResults([]); setDone(false); return; } + startLookup(q); + return () => esRef.current?.close(); + }, [q, startLookup]); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = searchInput.trim().replace(/^@/, ""); - if (trimmed && trimmed !== handle) { - router.push(`/lookup/${encodeURIComponent(trimmed)}`); - } - }; + const counts = results.reduce>((acc, r) => { + acc.all = (acc.all ?? 0) + 1; + acc[r.category] = (acc[r.category] ?? 0) + 1; + return acc; + }, {}); - const available = results.filter((r) => r.status === "available").length; - const taken = results.filter((r) => r.status === "taken").length; + const activeCat = (category ?? "featured") as Category | "all"; + const filtered = activeCat === "all" ? results : results.filter((r) => r.category === activeCat); + const totalPlatforms = PLATFORMS.length; + const categoryTotal = activeCat === "all" ? totalPlatforms : PLATFORMS.filter((p) => p.category === activeCat).length; + const skeletonCount = !done ? Math.max(0, categoryTotal - filtered.length) : 0; - return ( -
- {/* Search bar */} -
- setSearchInput(e.target.value)} - placeholder="Try another handle…" - className="h-9 max-w-xs" - /> - -
+ if (!q) { + return ( +
+

+ Enter a handle above — we'll check availability across {totalPlatforms} platforms instantly. +

+ +
+ ); + } - {/* Header */} -
-

- Results for @{handle} -

-
- {!done ? ( - <> - - - Checking {results.length} /{" "} - {totalPlatforms || "…"} platforms… - - - ) : ( - - Checked {results.length} platforms —{" "} - - {available} available - - {", "} - - {taken} taken - - - )} + return ( +
+
+

@{q}

+
+
+ {!done && ( +
+
+ )} +
+ setCategory(cat)} />
- - {/* Results grid */}
- {results.map((result) => ( - + {filtered.map((result, i) => ( + + ))} + {Array.from({ length: skeletonCount }, (_, i) => ( + ))} - {/* Skeleton placeholders while loading */} - {!done && - Array.from({ - length: Math.max(0, (totalPlatforms || 6) - results.length), - }).map((_, i) => )}
); diff --git a/src/lib/lookup.ts b/src/lib/lookup.ts index d145456..1f872fa 100644 --- a/src/lib/lookup.ts +++ b/src/lib/lookup.ts @@ -14,41 +14,54 @@ const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; const TIMEOUT_MS = 10_000; +function buildPayload( + payload: Record, + handle: string +): string { + return JSON.stringify(payload).replace(/\{\}/g, handle); +} + async function checkPlatform( platform: Platform, handle: string ): Promise { - const url = platform.url.replace(/\{\}/g, encodeURIComponent(handle)); - const profileUrl = platform.url.replace(/\{\}/g, handle); // unencoded for display + const profileUrl = platform.url.replace(/\{\}/g, handle); const start = Date.now(); - // Validate username format if regex provided - if (platform.usernameRegex) { - const regex = new RegExp(platform.usernameRegex); - if (!regex.test(handle)) { - return { - platform: platform.name, - category: platform.category, - url: profileUrl, - status: "unknown", - responseTime: 0, - }; + // Skip if handle fails the platform's regex + if (platform.regexCheck) { + try { + if (!new RegExp(platform.regexCheck).test(handle)) { + return { platform: platform.name, category: platform.category, url: profileUrl, status: "unknown", responseTime: 0 }; + } + } catch { + // ignore invalid regex } } + const isPost = platform.requestMethod === "POST"; + // Use urlProbe if provided, otherwise use profile url + const fetchUrl = (platform.urlProbe ?? platform.url).replace( + /\{\}/g, + encodeURIComponent(handle) + ); + try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - const response = await fetch(url, { - method: "GET", + const response = await fetch(fetchUrl, { + method: isPost ? "POST" : "GET", headers: { "User-Agent": USER_AGENT, - Accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", - ...platform.requestHeaders, + ...(isPost ? { "Content-Type": "application/json" } : {}), + ...platform.headers, }, + body: isPost && platform.requestPayload + ? buildPayload(platform.requestPayload, handle) + : undefined, redirect: "follow", signal: controller.signal, }); @@ -115,17 +128,24 @@ async function checkPlatform( } /** - * Check all platforms for a handle. Calls onResult as each check completes. + * Check all platforms concurrently (max `concurrency` in flight at once). + * Calls `onResult` as each check completes — ideal for SSE streaming. */ export async function checkAllPlatforms( handle: string, - onResult: (result: PlatformResult) => void + onResult: (result: PlatformResult) => void, + concurrency = 50 ): Promise { - await Promise.allSettled( - PLATFORMS.map(async (platform) => { - const result = await checkPlatform(platform, handle); - onResult(result); - }) + const queue = [...PLATFORMS]; + const worker = async () => { + while (queue.length > 0) { + const platform = queue.shift(); + if (!platform) break; + onResult(await checkPlatform(platform, handle)); + } + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, PLATFORMS.length) }, worker) ); } diff --git a/src/lib/platforms.ts b/src/lib/platforms.ts index 661250b..c5ffba5 100644 --- a/src/lib/platforms.ts +++ b/src/lib/platforms.ts @@ -1,323 +1,205 @@ +import sherlockData from "./sherlock-data.json"; + export type ErrorType = "status_code" | "message" | "response_url"; export type Category = + | "featured" | "social" | "developer" | "gaming" | "creative" | "music" | "writing" - | "professional"; + | "other"; export interface Platform { name: string; category: Category; - url: string; // Profile URL template, {} = username - urlMain: string; // Platform homepage + url: string; + urlMain: string; + urlProbe?: string; errorType: ErrorType; - errorCode?: number; // Status code = user NOT found (default 404 for status_code type) - errorMsg?: string[]; // Body strings = user NOT found (for message type) - errorUrl?: string; // Response URL fragment = user NOT found (for response_url type) - requestHeaders?: Record; - usernameRegex?: string; // Optional: regex to validate username format before checking + errorCode?: number; + errorMsg?: string[]; + errorUrl?: string; + headers?: Record; + requestMethod?: "GET" | "POST"; + requestPayload?: Record; + regexCheck?: string; } -export const PLATFORMS: Platform[] = [ - // === DEVELOPER === - { name: "GitHub", category: "developer", url: "https://github.com/{}", urlMain: "https://github.com", errorType: "status_code" }, - { name: "GitLab", category: "developer", url: "https://gitlab.com/{}", urlMain: "https://gitlab.com", errorType: "status_code" }, - { name: "Bitbucket", category: "developer", url: "https://bitbucket.org/{}", urlMain: "https://bitbucket.org", errorType: "status_code" }, - { name: "npm", category: "developer", url: "https://www.npmjs.com/~{}", urlMain: "https://www.npmjs.com", errorType: "status_code" }, - { name: "PyPI", category: "developer", url: "https://pypi.org/user/{}/", urlMain: "https://pypi.org", errorType: "status_code" }, - { name: "Dev.to", category: "developer", url: "https://dev.to/{}", urlMain: "https://dev.to", errorType: "status_code" }, - { name: "HackerNews", category: "developer", url: "https://hn.algolia.com/api/v1/users/{}", urlMain: "https://news.ycombinator.com", errorType: "status_code" }, - { name: "CodePen", category: "developer", url: "https://codepen.io/{}", urlMain: "https://codepen.io", errorType: "status_code" }, - { name: "itch.io", category: "developer", url: "https://{}.itch.io", urlMain: "https://itch.io", errorType: "status_code" }, +// ── Category Rules ────────────────────────────────────────────────────────── - // === SOCIAL === - { - name: "Reddit", - category: "social", - url: "https://www.reddit.com/user/{}", - urlMain: "https://www.reddit.com", - errorType: "status_code", - }, - { - name: "X (Twitter)", - category: "social", - url: "https://x.com/{}", - urlMain: "https://x.com", - errorType: "status_code", - requestHeaders: { "Accept-Language": "en-US,en;q=0.9" }, - }, - { - name: "Instagram", - category: "social", - url: "https://www.instagram.com/{}/", - urlMain: "https://www.instagram.com", - errorType: "status_code", - }, - { - name: "TikTok", - category: "social", - url: "https://www.tiktok.com/@{}", - urlMain: "https://www.tiktok.com", - errorType: "status_code", - }, - { - name: "Pinterest", - category: "social", - url: "https://www.pinterest.com/{}/", - urlMain: "https://www.pinterest.com", - errorType: "status_code", - }, - { - name: "Tumblr", - category: "social", - url: "https://{}.tumblr.com/", - urlMain: "https://www.tumblr.com", - errorType: "message", - errorMsg: ["There's nothing here.", "Not found.", "Whatever you were looking for doesn't live here"], - }, - { - name: "Snapchat", - category: "social", - url: "https://www.snapchat.com/add/{}", - urlMain: "https://www.snapchat.com", - errorType: "message", - errorMsg: ["Sorry, we couldn't find", "Sorry, we can't find"], - }, - { - name: "Telegram", - category: "social", - url: "https://t.me/{}", - urlMain: "https://telegram.org", - errorType: "message", - errorMsg: ["If you have Telegram, you can contact", "Sorry, this username doesn't exist"], - usernameRegex: "^[a-zA-Z][a-zA-Z0-9_]{4,}$", - }, - { - name: "Mastodon", - category: "social", - url: "https://mastodon.social/api/v1/accounts/lookup?acct={}", - urlMain: "https://mastodon.social", - errorType: "status_code", - }, - { - name: "Bluesky", - category: "social", - url: "https://bsky.app/profile/{}", - urlMain: "https://bsky.app", - errorType: "status_code", - }, +const FEATURED = new Set([ + "GitHub", + "Instagram", + "Twitter", + "TikTok", + "YouTube", + "Reddit", + "LinkedIn", + "Snapchat", + "Pinterest", + "Twitch", + "Discord", + "Telegram", + "SoundCloud", + "Medium", + "GitLab", + "Spotify", + "Behance", + "Dribbble", + "Patreon", + "DeviantArt", + "HackerNews", + "last.fm", + "Keybase", + "Vimeo", + "Bluesky", + "Codepen", + "Linktree", + "ProductHunt", + "npm", +]); - // === VIDEO/STREAMING === - { - name: "YouTube", - category: "social", - url: "https://www.youtube.com/@{}", - urlMain: "https://www.youtube.com", - errorType: "status_code", - }, - { - name: "Twitch", - category: "gaming", - url: "https://www.twitch.tv/{}", - urlMain: "https://www.twitch.tv", - errorType: "status_code", - }, - { - name: "Vimeo", - category: "social", - url: "https://vimeo.com/{}", - urlMain: "https://vimeo.com", - errorType: "status_code", - }, +const DEVELOPER_KEYS = [ + "github", "gitlab", "bitbucket", "npm", "pypi", "stackoverflow", + "codepen", "replit", "hackerrank", "leetcode", "codeforces", "coderwall", + "sourcehut", "launchpad", "bugcrowd", "hackerone", "keybase", + "tryhackme", "hackthebox", "hackernews", "devto", "hackster", "kaggle", + "gitea", "codeberg", "codeproject", "codewars", "exercism", "topcoder", + "spoj", "atcoder", "jsfiddle", "ideone", "pastebin", +]; - // === MUSIC === - { - name: "SoundCloud", - category: "music", - url: "https://soundcloud.com/{}", - urlMain: "https://soundcloud.com", - errorType: "status_code", - }, - { - name: "Last.fm", - category: "music", - url: "https://www.last.fm/user/{}", - urlMain: "https://www.last.fm", - errorType: "status_code", - }, - { - name: "Bandcamp", - category: "music", - url: "https://{}.bandcamp.com/", - urlMain: "https://bandcamp.com", - errorType: "status_code", - }, +const GAMING_KEYS = [ + "twitch", "steam", "roblox", "chess", "lichess", "xbox", "playstation", + "nintendo", "battlenet", "epicgames", "ubisoft", "origin", "gog", + "itchio", "speedrun", "kongregate", "faceit", "battlefy", "challonge", + "gamejolt", "newgrounds", "armorgames", "psnprofiles", "razer", + "alienware", "esport", "gamertag", "duolingo", +]; - // === CREATIVE === - { - name: "Behance", - category: "creative", - url: "https://www.behance.net/{}", - urlMain: "https://www.behance.net", - errorType: "status_code", - }, - { - name: "Dribbble", - category: "creative", - url: "https://dribbble.com/{}", - urlMain: "https://dribbble.com", - errorType: "status_code", - }, - { - name: "DeviantArt", - category: "creative", - url: "https://www.deviantart.com/{}", - urlMain: "https://www.deviantart.com", - errorType: "status_code", - }, - { - name: "Flickr", - category: "creative", - url: "https://www.flickr.com/people/{}/", - urlMain: "https://www.flickr.com", - errorType: "message", - errorMsg: ["Oops! We couldn't find that page.", "page not found"], - }, - { - name: "Redbubble", - category: "creative", - url: "https://www.redbubble.com/people/{}/shop", - urlMain: "https://www.redbubble.com", - errorType: "status_code", - }, - { - name: "ArtStation", - category: "creative", - url: "https://www.artstation.com/{}", - urlMain: "https://www.artstation.com", - errorType: "status_code", - }, +const CREATIVE_KEYS = [ + "behance", "dribbble", "flickr", "deviantart", "artstation", "redbubble", + "society6", "zazzle", "500px", "unsplash", "pixiv", "carbonmade", + "coroflot", "vsco", "smugmug", "fineartamerica", "saatchiart", + "threadless", "teepublic", "cargo", "portfoliobox", "designspiration", +]; - // === WRITING/BLOGGING === - { - name: "Medium", - category: "writing", - url: "https://medium.com/@{}", - urlMain: "https://medium.com", - errorType: "status_code", - }, - { - name: "Substack", - category: "writing", - url: "https://{}.substack.com", - urlMain: "https://substack.com", - errorType: "status_code", - }, - { - name: "Wattpad", - category: "writing", - url: "https://www.wattpad.com/user/{}", - urlMain: "https://www.wattpad.com", - errorType: "status_code", - }, +const MUSIC_KEYS = [ + "soundcloud", "spotify", "lastfm", "bandcamp", "mixcloud", "reverbnation", + "audiomack", "genius", "musixmatch", "beatport", "traxsource", "discogs", + "rateyourmusic", "gaana", "anghami", +]; - // === GAMING === - { - name: "Steam", - category: "gaming", - url: "https://steamcommunity.com/id/{}", - urlMain: "https://steamcommunity.com", - errorType: "message", - errorMsg: ["The specified profile could not be found.", "this user has not yet set up"], - }, - { - name: "Chess.com", - category: "gaming", - url: "https://www.chess.com/member/{}", - urlMain: "https://www.chess.com", - errorType: "status_code", - }, - { - name: "Lichess", - category: "gaming", - url: "https://lichess.org/@/{}/all", - urlMain: "https://lichess.org", - errorType: "status_code", - }, - { - name: "Roblox", - category: "gaming", - url: "https://www.roblox.com/user.aspx?username={}", - urlMain: "https://www.roblox.com", - errorType: "message", - errorMsg: ["Profile is not found", "page does not exist"], - }, +const WRITING_KEYS = [ + "medium", "substack", "wattpad", "goodreads", "tumblr", "wordpress", + "livejournal", "fanfiction", "quotev", "movellas", "inkitt", "ao3", + "blog", "ghost", "hashnode", "blogger", "weebly", +]; - // === PROFESSIONAL / OTHER === - { - name: "LinkedIn", - category: "professional", - url: "https://www.linkedin.com/in/{}/", - urlMain: "https://www.linkedin.com", - errorType: "status_code", - }, +function getCategory(name: string, urlMain: string): Category { + if (FEATURED.has(name)) return "featured"; + const lower = (name + " " + urlMain) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + if (DEVELOPER_KEYS.some((k) => lower.includes(k.replace(/[^a-z0-9]/g, "")))) return "developer"; + if (GAMING_KEYS.some((k) => lower.includes(k.replace(/[^a-z0-9]/g, "")))) return "gaming"; + if (MUSIC_KEYS.some((k) => lower.includes(k.replace(/[^a-z0-9]/g, "")))) return "music"; + if (WRITING_KEYS.some((k) => lower.includes(k.replace(/[^a-z0-9]/g, "")))) return "writing"; + if (CREATIVE_KEYS.some((k) => lower.includes(k.replace(/[^a-z0-9]/g, "")))) return "creative"; + return "social"; +} + +// ── Transform Sherlock data ────────────────────────────────────────────────── + +type SherlockEntry = { + errorType: string; + errorCode?: number; + errorMsg?: string | string[]; + errorUrl?: string; + url: string; + urlMain: string; + urlProbe?: string; + headers?: Record; + request_method?: string; + request_payload?: Record; + regexCheck?: string; + isNSFW?: boolean; +}; + +const rawData = sherlockData as unknown as Record; + +export const PLATFORMS: Platform[] = Object.entries(rawData) + .filter(([key, p]) => key !== "$schema" && !p.isNSFW) + .map(([name, p]) => ({ + name, + category: getCategory(name, p.urlMain), + url: p.url, + urlMain: p.urlMain, + urlProbe: p.urlProbe, + errorType: p.errorType as ErrorType, + errorCode: p.errorCode, + errorMsg: p.errorMsg + ? Array.isArray(p.errorMsg) + ? p.errorMsg + : [p.errorMsg] + : undefined, + errorUrl: p.errorUrl, + headers: p.headers, + requestMethod: p.request_method as "GET" | "POST" | undefined, + requestPayload: p.request_payload, + regexCheck: p.regexCheck, + })) + .sort((a, b) => { + if (a.category === "featured" && b.category !== "featured") return -1; + if (a.category !== "featured" && b.category === "featured") return 1; + return a.name.localeCompare(b.name); + }); + +export const CATEGORIES: { + id: Category | "all"; + label: string; + description: string; +}[] = [ + { id: "all", label: "All", description: `${PLATFORMS.length} platforms` }, { - name: "Keybase", - category: "professional", - url: "https://keybase.io/{}", - urlMain: "https://keybase.io", - errorType: "status_code", + id: "featured", + label: "Featured", + description: `${PLATFORMS.filter((p) => p.category === "featured").length} platforms`, }, { - name: "Ko-fi", - category: "professional", - url: "https://ko-fi.com/{}", - urlMain: "https://ko-fi.com", - errorType: "status_code", + id: "social", + label: "Social", + description: `${PLATFORMS.filter((p) => p.category === "social").length} platforms`, }, { - name: "Patreon", - category: "professional", - url: "https://www.patreon.com/{}", - urlMain: "https://www.patreon.com", - errorType: "status_code", + id: "developer", + label: "Developer", + description: `${PLATFORMS.filter((p) => p.category === "developer").length} platforms`, }, { - name: "Fiverr", - category: "professional", - url: "https://www.fiverr.com/{}", - urlMain: "https://www.fiverr.com", - errorType: "status_code", + id: "gaming", + label: "Gaming", + description: `${PLATFORMS.filter((p) => p.category === "gaming").length} platforms`, }, { - name: "Linktree", - category: "professional", - url: "https://linktr.ee/{}", - urlMain: "https://linktr.ee", - errorType: "status_code", + id: "creative", + label: "Creative", + description: `${PLATFORMS.filter((p) => p.category === "creative").length} platforms`, }, { - name: "Duolingo", - category: "professional", - url: "https://www.duolingo.com/profile/{}", - urlMain: "https://www.duolingo.com", - errorType: "status_code", + id: "music", + label: "Music", + description: `${PLATFORMS.filter((p) => p.category === "music").length} platforms`, }, { - name: "About.me", - category: "professional", - url: "https://about.me/{}", - urlMain: "https://about.me", - errorType: "status_code", + id: "writing", + label: "Writing", + description: `${PLATFORMS.filter((p) => p.category === "writing").length} platforms`, }, { - name: "Product Hunt", - category: "professional", - url: "https://www.producthunt.com/@{}", - urlMain: "https://www.producthunt.com", - errorType: "status_code", + id: "other", + label: "Other", + description: `${PLATFORMS.filter((p) => p.category === "other").length} platforms`, }, ]; diff --git a/src/lib/sherlock-data.json b/src/lib/sherlock-data.json new file mode 100644 index 0000000..47c3943 --- /dev/null +++ b/src/lib/sherlock-data.json @@ -0,0 +1,3283 @@ +{ + "$schema": "data.schema.json", + "1337x": { + "errorMsg": [ + "Error something went wrong.", + "404 Not Found" + ], + "errorType": "message", + "regexCheck": "^[A-Za-z0-9]{4,12}$", + "url": "https://www.1337x.to/user/{}/", + "urlMain": "https://www.1337x.to/", + "username_claimed": "FitGirl" + }, + "2Dimensions": { + "errorType": "status_code", + "url": "https://2Dimensions.com/a/{}", + "urlMain": "https://2Dimensions.com/", + "username_claimed": "blue" + }, + "7Cups": { + "errorType": "status_code", + "url": "https://www.7cups.com/@{}", + "urlMain": "https://www.7cups.com/", + "username_claimed": "blue" + }, + "9GAG": { + "errorType": "status_code", + "url": "https://www.9gag.com/u/{}", + "urlMain": "https://www.9gag.com/", + "username_claimed": "blue" + }, + "APClips": { + "errorMsg": "Amateur Porn Content Creators", + "errorType": "message", + "isNSFW": true, + "url": "https://apclips.com/{}", + "urlMain": "https://apclips.com/", + "username_claimed": "onlybbyraq" + }, + "About.me": { + "errorType": "status_code", + "url": "https://about.me/{}", + "urlMain": "https://about.me/", + "username_claimed": "blue" + }, + "Academia.edu": { + "errorType": "status_code", + "regexCheck": "^[^.]*$", + "url": "https://independent.academia.edu/{}", + "urlMain": "https://www.academia.edu/", + "username_claimed": "blue" + }, + "AdmireMe.Vip": { + "errorMsg": "Page Not Found", + "errorType": "message", + "isNSFW": true, + "url": "https://admireme.vip/{}", + "urlMain": "https://admireme.vip/", + "username_claimed": "DemiDevil" + }, + "Airbit": { + "errorType": "status_code", + "url": "https://airbit.com/{}", + "urlMain": "https://airbit.com/", + "username_claimed": "airbit" + }, + "Airliners": { + "errorType": "status_code", + "url": "https://www.airliners.net/user/{}/profile/photos", + "urlMain": "https://www.airliners.net/", + "username_claimed": "yushinlin" + }, + "All Things Worn": { + "errorMsg": "Sell Used Panties", + "errorType": "message", + "isNSFW": true, + "url": "https://www.allthingsworn.com/profile/{}", + "urlMain": "https://www.allthingsworn.com", + "username_claimed": "pink" + }, + "AllMyLinks": { + "errorMsg": "Page not found", + "errorType": "message", + "regexCheck": "^[a-z0-9][a-z0-9-]{2,32}$", + "url": "https://allmylinks.com/{}", + "urlMain": "https://allmylinks.com/", + "username_claimed": "blue" + }, + "AniWorld": { + "errorMsg": "Dieses Profil ist nicht verf\u00fcgbar", + "errorType": "message", + "url": "https://aniworld.to/user/profil/{}", + "urlMain": "https://aniworld.to/", + "username_claimed": "blue" + }, + "Anilist": { + "errorType": "status_code", + "regexCheck": "^[A-Za-z0-9]{2,20}$", + "request_method": "POST", + "request_payload": { + "query": "query($name:String){User(name:$name){id}}", + "variables": { + "name": "{}" + } + }, + "url": "https://anilist.co/user/{}/", + "urlMain": "https://anilist.co/", + "urlProbe": "https://graphql.anilist.co/", + "username_claimed": "Josh" + }, + "Apple Developer": { + "errorType": "status_code", + "url": "https://developer.apple.com/forums/profile/{}", + "urlMain": "https://developer.apple.com", + "username_claimed": "lio24d" + }, + "Apple Discussions": { + "errorMsg": "Looking for something in Apple Support Communities?", + "errorType": "message", + "url": "https://discussions.apple.com/profile/{}", + "urlMain": "https://discussions.apple.com", + "username_claimed": "jason" + }, + "Aparat": { + "errorType": "status_code", + "request_method": "GET", + "url": "https://www.aparat.com/{}/", + "urlMain": "https://www.aparat.com/", + "urlProbe": "https://www.aparat.com/api/fa/v1/user/user/information/username/{}", + "username_claimed": "jadi" + }, + "Archive of Our Own": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://archiveofourown.org/users/{}", + "urlMain": "https://archiveofourown.org/", + "username_claimed": "test" + }, + "Archive.org": { + "__comment__": "'The resource could not be found' relates to archive downtime", + "errorMsg": [ + "could not fetch an account with user item identifier", + "The resource could not be found", + "Internet Archive services are temporarily offline" + ], + "errorType": "message", + "url": "https://archive.org/details/@{}", + "urlMain": "https://archive.org", + "urlProbe": "https://archive.org/details/@{}?noscript=true", + "username_claimed": "blue" + }, + "Arduino Forum": { + "errorType": "status_code", + "url": "https://forum.arduino.cc/u/{}/summary", + "urlMain": "https://forum.arduino.cc/", + "username_claimed": "system" + }, + "ArtStation": { + "errorType": "status_code", + "url": "https://www.artstation.com/{}", + "urlMain": "https://www.artstation.com/", + "username_claimed": "Blue" + }, + "Asciinema": { + "errorType": "status_code", + "url": "https://asciinema.org/~{}", + "urlMain": "https://asciinema.org", + "username_claimed": "red" + }, + "Ask Fedora": { + "errorType": "status_code", + "url": "https://ask.fedoraproject.org/u/{}", + "urlMain": "https://ask.fedoraproject.org/", + "username_claimed": "red" + }, + "Atcoder": { + "errorType": "status_code", + "url": "https://atcoder.jp/users/{}", + "urlMain": "https://atcoder.jp/", + "username_claimed": "ksun48" + }, + "Vjudge": { + "errorType": "status_code", + "url": "https://VJudge.net/user/{}", + "urlMain": "https://VJudge.net/", + "username_claimed": "tokitsukaze" + }, + "Audiojungle": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9_]+$", + "url": "https://audiojungle.net/user/{}", + "urlMain": "https://audiojungle.net/", + "username_claimed": "blue" + }, + "Autofrage": { + "errorType": "status_code", + "url": "https://www.autofrage.net/nutzer/{}", + "urlMain": "https://www.autofrage.net/", + "username_claimed": "autofrage" + }, + "Avizo": { + "errorType": "response_url", + "errorUrl": "https://www.avizo.cz/", + "url": "https://www.avizo.cz/{}/", + "urlMain": "https://www.avizo.cz/", + "username_claimed": "blue" + }, + "AWS Skills Profile": { + "errorType": "message", + "errorMsg": "shareProfileAccepted\":false", + "url": "https://skillsprofile.skillbuilder.aws/user/{}/", + "urlMain": "https://skillsprofile.skillbuilder.aws", + "username_claimed": "mayank04pant" + }, + "BOOTH": { + "errorType": "response_url", + "errorUrl": "https://booth.pm/", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.booth.pm/", + "urlMain": "https://booth.pm/", + "username_claimed": "blue" + }, + "Bandcamp": { + "errorType": "status_code", + "url": "https://www.bandcamp.com/{}", + "urlMain": "https://www.bandcamp.com/", + "username_claimed": "blue" + }, + "Bazar.cz": { + "errorType": "response_url", + "errorUrl": "https://www.bazar.cz/error404.aspx", + "url": "https://www.bazar.cz/{}/", + "urlMain": "https://www.bazar.cz/", + "username_claimed": "pianina" + }, + "Behance": { + "errorType": "status_code", + "url": "https://www.behance.net/{}", + "urlMain": "https://www.behance.net/", + "username_claimed": "blue" + }, + "Bezuzyteczna": { + "errorType": "status_code", + "url": "https://bezuzyteczna.pl/uzytkownicy/{}", + "urlMain": "https://bezuzyteczna.pl", + "username_claimed": "Jackson" + }, + "BiggerPockets": { + "errorType": "status_code", + "url": "https://www.biggerpockets.com/users/{}", + "urlMain": "https://www.biggerpockets.com/", + "username_claimed": "blue" + }, + "BioHacking": { + "errorType": "status_code", + "url": "https://forum.dangerousthings.com/u/{}", + "urlMain": "https://forum.dangerousthings.com/", + "username_claimed": "blue" + }, + "BitBucket": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9-_]{1,30}$", + "url": "https://bitbucket.org/{}/", + "urlMain": "https://bitbucket.org/", + "username_claimed": "white" + }, + "Bitwarden Forum": { + "errorType": "status_code", + "regexCheck": "^(?![.-])[a-zA-Z0-9_.-]{3,20}$", + "url": "https://community.bitwarden.com/u/{}/summary", + "urlMain": "https://bitwarden.com/", + "username_claimed": "blue" + }, + "Blipfoto": { + "errorType": "status_code", + "url": "https://www.blipfoto.com/{}", + "urlMain": "https://www.blipfoto.com/", + "username_claimed": "blue" + }, + "Blitz Tactics": { + "errorMsg": "That page doesn't exist", + "errorType": "message", + "url": "https://blitztactics.com/{}", + "urlMain": "https://blitztactics.com/", + "username_claimed": "Lance5500" + }, + "Blogger": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://{}.blogspot.com", + "urlMain": "https://www.blogger.com/", + "username_claimed": "blue" + }, + "Bluesky": { + "errorType": "status_code", + "url": "https://bsky.app/profile/{}.bsky.social", + "urlProbe": "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}.bsky.social", + "urlMain": "https://bsky.app/", + "username_claimed": "mcuban" + }, + "BongaCams": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://pt.bongacams.com/profile/{}", + "urlMain": "https://pt.bongacams.com", + "username_claimed": "asuna-black" + }, + "Bookcrossing": { + "errorType": "status_code", + "url": "https://www.bookcrossing.com/mybookshelf/{}/", + "urlMain": "https://www.bookcrossing.com/", + "username_claimed": "blue" + }, + "BoardGameGeek": { + "errorMsg": "\"isValid\":true", + "errorType": "message", + "url": "https://boardgamegeek.com/user/{}", + "urlMain": "https://boardgamegeek.com/", + "urlProbe": "https://api.geekdo.com/api/accounts/validate/username?username={}", + "username_claimed": "blue" + }, + "BraveCommunity": { + "errorType": "status_code", + "url": "https://community.brave.com/u/{}/", + "urlMain": "https://community.brave.com/", + "username_claimed": "blue" + }, + "BreachSta.rs Forum": { + "errorMsg": "Error - BreachStars", + "errorType": "message", + "url": "https://breachsta.rs/profile/{}", + "urlMain": "https://breachsta.rs/", + "username_claimed": "Sleepybubble" + }, + "BugCrowd": { + "errorType": "status_code", + "url": "https://bugcrowd.com/{}", + "urlMain": "https://bugcrowd.com/", + "username_claimed": "ppfeister" + }, + "BuyMeACoffee": { + "errorType": "status_code", + "regexCheck": "[a-zA-Z0-9]{3,15}", + "url": "https://buymeacoff.ee/{}", + "urlMain": "https://www.buymeacoffee.com/", + "urlProbe": "https://www.buymeacoffee.com/{}", + "username_claimed": "red" + }, + "BuzzFeed": { + "errorType": "status_code", + "url": "https://buzzfeed.com/{}", + "urlMain": "https://buzzfeed.com/", + "username_claimed": "blue" + }, + "Cfx.re Forum": { + "errorType": "status_code", + "url": "https://forum.cfx.re/u/{}/summary", + "urlMain": "https://forum.cfx.re", + "username_claimed": "hightowerlssd" + }, + "CGTrader": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://www.cgtrader.com/{}", + "urlMain": "https://www.cgtrader.com", + "username_claimed": "blue" + }, + "CNET": { + "errorType": "status_code", + "regexCheck": "^[a-z].*$", + "url": "https://www.cnet.com/profiles/{}/", + "urlMain": "https://www.cnet.com/", + "username_claimed": "melliott" + }, + "CSSBattle": { + "errorType": "status_code", + "url": "https://cssbattle.dev/player/{}", + "urlMain": "https://cssbattle.dev", + "username_claimed": "beo" + }, + "CTAN": { + "errorType": "status_code", + "url": "https://ctan.org/author/{}", + "urlMain": "https://ctan.org/", + "username_claimed": "briggs" + }, + "Caddy Community": { + "errorType": "status_code", + "url": "https://caddy.community/u/{}/summary", + "urlMain": "https://caddy.community/", + "username_claimed": "taako_magnusen" + }, + "Car Talk Community": { + "errorType": "status_code", + "url": "https://community.cartalk.com/u/{}/summary", + "urlMain": "https://community.cartalk.com/", + "username_claimed": "always_fixing" + }, + "Carbonmade": { + "errorType": "response_url", + "errorUrl": "https://carbonmade.com/fourohfour?domain={}.carbonmade.com", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.carbonmade.com", + "urlMain": "https://carbonmade.com/", + "username_claimed": "jenny" + }, + "Career.habr": { + "errorMsg": "

\u041e\u0448\u0438\u0431\u043a\u0430 404

", + "errorType": "message", + "url": "https://career.habr.com/{}", + "urlMain": "https://career.habr.com/", + "username_claimed": "blue" + }, + "CashApp": { + "errorType": "status_code", + "url": "https://cash.app/${}", + "urlMain": "https://cash.app", + "username_claimed": "hotdiggitydog" + }, + "Championat": { + "errorType": "status_code", + "url": "https://www.championat.com/user/{}", + "urlMain": "https://www.championat.com/", + "username_claimed": "blue" + }, + "Chaos": { + "errorType": "status_code", + "url": "https://chaos.social/@{}", + "urlMain": "https://chaos.social/", + "username_claimed": "ordnung" + }, + "Chatujme.cz": { + "errorMsg": "Neexistujic\u00ed profil", + "errorType": "message", + "regexCheck": "^[a-zA-Z][a-zA-Z1-9_-]*$", + "url": "https://profil.chatujme.cz/{}", + "urlMain": "https://chatujme.cz/", + "username_claimed": "david" + }, + "ChaturBate": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://chaturbate.com/{}", + "urlMain": "https://chaturbate.com", + "username_claimed": "cute18cute" + }, + "Chess": { + "errorMsg": "Username is valid", + "errorType": "message", + "regexCheck": "^[a-zA-Z0-9_]{3,25}$", + "url": "https://www.chess.com/member/{}", + "urlMain": "https://www.chess.com/", + "urlProbe": "https://www.chess.com/callback/user/valid?username={}", + "username_claimed": "blue" + }, + "Choice Community": { + "errorType": "status_code", + "url": "https://choice.community/u/{}/summary", + "urlMain": "https://choice.community/", + "username_claimed": "gordon" + }, + "Clapper": { + "errorType": "status_code", + "url": "https://clapperapp.com/{}", + "urlMain": "https://clapperapp.com/", + "username_claimed": "blue" + }, + "CloudflareCommunity": { + "errorType": "status_code", + "url": "https://community.cloudflare.com/u/{}", + "urlMain": "https://community.cloudflare.com/", + "username_claimed": "blue" + }, + "Clozemaster": { + "errorMsg": "Oh no! Player not found.", + "errorType": "message", + "url": "https://www.clozemaster.com/players/{}", + "urlMain": "https://www.clozemaster.com", + "username_claimed": "green" + }, + "Clubhouse": { + "errorType": "status_code", + "url": "https://www.clubhouse.com/@{}", + "urlMain": "https://www.clubhouse.com", + "username_claimed": "waniathar" + }, + "Code Snippet Wiki": { + "errorMsg": "This user has not filled out their profile page yet", + "errorType": "message", + "url": "https://codesnippets.fandom.com/wiki/User:{}", + "urlMain": "https://codesnippets.fandom.com", + "username_claimed": "bob" + }, + "Codeberg": { + "errorType": "status_code", + "url": "https://codeberg.org/{}", + "urlMain": "https://codeberg.org/", + "username_claimed": "blue" + }, + "Codecademy": { + "errorMsg": "This profile could not be found", + "errorType": "message", + "url": "https://www.codecademy.com/profiles/{}", + "urlMain": "https://www.codecademy.com/", + "username_claimed": "blue" + }, + "Codechef": { + "errorType": "response_url", + "errorUrl": "https://www.codechef.com/", + "url": "https://www.codechef.com/users/{}", + "urlMain": "https://www.codechef.com/", + "username_claimed": "blue" + }, + "Codeforces": { + "errorType": "status_code", + "url": "https://codeforces.com/profile/{}", + "urlMain": "https://codeforces.com/", + "urlProbe": "https://codeforces.com/api/user.info?handles={}", + "username_claimed": "tourist" + }, + "Codepen": { + "errorType": "status_code", + "url": "https://codepen.io/{}", + "urlMain": "https://codepen.io/", + "username_claimed": "blue" + }, + "Coders Rank": { + "errorMsg": "not a registered member", + "errorType": "message", + "regexCheck": "^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$", + "url": "https://profile.codersrank.io/user/{}/", + "urlMain": "https://codersrank.io/", + "username_claimed": "rootkit7628" + }, + "Coderwall": { + "errorType": "status_code", + "url": "https://coderwall.com/{}", + "urlMain": "https://coderwall.com", + "username_claimed": "hacker" + }, + "CodeSandbox": { + "errorType": "message", + "errorMsg": "Could not find user with username", + "regexCheck": "^[a-zA-Z0-9_-]{3,30}$", + "url": "https://codesandbox.io/u/{}", + "urlProbe": "https://codesandbox.io/api/v1/users/{}", + "urlMain": "https://codesandbox.io", + "username_claimed": "icyjoseph" + }, + "Codewars": { + "errorType": "status_code", + "url": "https://www.codewars.com/users/{}", + "urlMain": "https://www.codewars.com", + "username_claimed": "example" + }, + "Codolio": { + "errorType": "message", + "errorMsg": "Page Not Found | Codolio", + "url": "https://codolio.com/profile/{}", + "urlMain": "https://codolio.com/", + "username_claimed": "testuser", + "regexCheck": "^[a-zA-Z0-9_-]{3,30}$" + }, + "Coinvote": { + "errorType": "status_code", + "url": "https://coinvote.cc/profile/{}", + "urlMain": "https://coinvote.cc/", + "username_claimed": "blue" + }, + "ColourLovers": { + "errorType": "status_code", + "url": "https://www.colourlovers.com/lover/{}", + "urlMain": "https://www.colourlovers.com/", + "username_claimed": "blue" + }, + "Contently": { + "errorType": "response_url", + "errorUrl": "https://contently.com", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://{}.contently.com/", + "urlMain": "https://contently.com/", + "username_claimed": "jordanteicher" + }, + "Coroflot": { + "errorType": "status_code", + "url": "https://www.coroflot.com/{}", + "urlMain": "https://coroflot.com/", + "username_claimed": "blue" + }, + "Cplusplus": { + "errorType": "message", + "errorMsg": "404 Page Not Found", + "url": "https://cplusplus.com/user/{}", + "urlMain": "https://cplusplus.com", + "username_claimed": "mbozzi" + }, + "Cracked": { + "errorType": "response_url", + "errorUrl": "https://www.cracked.com/", + "url": "https://www.cracked.com/members/{}/", + "urlMain": "https://www.cracked.com/", + "username_claimed": "blue" + }, + "Cracked Forum": { + "errorMsg": "The member you specified is either invalid or doesn't exist", + "errorType": "message", + "url": "https://cracked.sh/{}", + "urlMain": "https://cracked.sh/", + "username_claimed": "Blue" + }, + "Credly": { + "errorType": "status_code", + "url": "https://www.credly.com/users/{}", + "urlMain": "https://www.credly.com/", + "username_claimed": "credly" + }, + "Crevado": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.crevado.com", + "urlMain": "https://crevado.com/", + "username_claimed": "blue" + }, + "Crowdin": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9._-]{2,255}$", + "url": "https://crowdin.com/profile/{}", + "urlMain": "https://crowdin.com/", + "username_claimed": "blue" + }, + "CryptoHack": { + "errorType": "response_url", + "errorUrl": "https://cryptohack.org/", + "url": "https://cryptohack.org/user/{}/", + "urlMain": "https://cryptohack.org/", + "username_claimed": "blue" + }, + "Cryptomator Forum": { + "errorType": "status_code", + "url": "https://community.cryptomator.org/u/{}", + "urlMain": "https://community.cryptomator.org/", + "username_claimed": "michael" + }, + "Cults3D": { + "errorMsg": "Oh dear, this page is not working!", + "errorType": "message", + "url": "https://cults3d.com/en/users/{}/creations", + "urlMain": "https://cults3d.com/en", + "username_claimed": "brown" + }, + "CyberDefenders": { + "errorType": "status_code", + "regexCheck": "^[^\\/:*?\"<>|@]{3,50}$", + "request_method": "GET", + "url": "https://cyberdefenders.org/p/{}", + "urlMain": "https://cyberdefenders.org/", + "username_claimed": "mlohn" + }, + "DEV Community": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://dev.to/{}", + "urlMain": "https://dev.to/", + "username_claimed": "blue" + }, + "DMOJ": { + "errorMsg": "No such user", + "errorType": "message", + "url": "https://dmoj.ca/user/{}", + "urlMain": "https://dmoj.ca/", + "username_claimed": "junferno" + }, + "DailyMotion": { + "errorType": "status_code", + "url": "https://www.dailymotion.com/{}", + "urlMain": "https://www.dailymotion.com/", + "username_claimed": "blue" + }, + "dcinside": { + "errorType": "status_code", + "url": "https://gallog.dcinside.com/{}", + "urlMain": "https://www.dcinside.com/", + "username_claimed": "anrbrb" + }, + "Dealabs": { + "errorMsg": "La page que vous essayez", + "errorType": "message", + "regexCheck": "[a-z0-9]{4,16}", + "url": "https://www.dealabs.com/profile/{}", + "urlMain": "https://www.dealabs.com/", + "username_claimed": "blue" + }, + "DeviantArt": { + "errorType": "message", + "errorMsg": "Llama Not Found", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://www.deviantart.com/{}", + "urlMain": "https://www.deviantart.com/", + "username_claimed": "blue" + }, + "DigitalSpy": { + "errorMsg": "The page you were looking for could not be found.", + "errorType": "message", + "url": "https://forums.digitalspy.com/profile/{}", + "urlMain": "https://forums.digitalspy.com/", + "username_claimed": "blue", + "regexCheck": "^\\w{3,20}$" + }, + "Discogs": { + "errorType": "status_code", + "url": "https://www.discogs.com/user/{}", + "urlMain": "https://www.discogs.com/", + "username_claimed": "blue" + }, + "Discord": { + "errorType": "message", + "url": "https://discord.com", + "urlMain": "https://discord.com/", + "urlProbe": "https://discord.com/api/v9/unique-username/username-attempt-unauthed", + "errorMsg": ["{\"taken\":false}", "The resource is being rate limited"], + "request_method": "POST", + "request_payload": { + "username": "{}" + }, + "headers": { + "Content-Type": "application/json" + }, + "username_claimed": "blue" + }, + "Discord.bio": { + "errorType": "message", + "errorMsg": "Server Error (500)", + "url": "https://discords.com/api-v2/bio/details/{}", + "urlMain": "https://discord.bio/", + "username_claimed": "robert" + }, + "Discuss.Elastic.co": { + "errorType": "status_code", + "url": "https://discuss.elastic.co/u/{}", + "urlMain": "https://discuss.elastic.co/", + "username_claimed": "blue" + }, + "Diskusjon.no": { + "errorMsg": "{\"result\":\"ok\"}", + "errorType": "message", + "regexCheck": "^[a-zA-Z0-9_.-]{3,40}$", + "urlProbe": "https://www.diskusjon.no/?app=core&module=system&controller=ajax&do=usernameExists&input={}", + "url": "https://www.diskusjon.no", + "urlMain": "https://www.diskusjon.no", + "username_claimed": "blue" + }, + "Disqus": { + "errorType": "status_code", + "url": "https://disqus.com/{}", + "urlMain": "https://disqus.com/", + "username_claimed": "blue" + }, + "Docker Hub": { + "errorType": "status_code", + "url": "https://hub.docker.com/u/{}/", + "urlMain": "https://hub.docker.com/", + "urlProbe": "https://hub.docker.com/v2/users/{}/", + "username_claimed": "blue" + }, + "Dribbble": { + "errorMsg": "Whoops, that page is gone.", + "errorType": "message", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://dribbble.com/{}", + "urlMain": "https://dribbble.com/", + "username_claimed": "blue" + }, + "Duolingo": { + "errorMsg": "{\"users\":[]}", + "errorType": "message", + "url": "https://www.duolingo.com/profile/{}", + "urlMain": "https://duolingo.com/", + "urlProbe": "https://www.duolingo.com/2017-06-30/users?username={}", + "username_claimed": "blue" + }, + "Eintracht Frankfurt Forum": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://community.eintracht.de/fans/{}", + "urlMain": "https://community.eintracht.de/", + "username_claimed": "mmammu" + }, + "Empretienda AR": { + "__comment__": "Note that Error Connecting responses may be indicative of unclaimed handles", + "errorType": "status_code", + "url": "https://{}.empretienda.com.ar", + "urlMain": "https://empretienda.com", + "username_claimed": "camalote" + }, + "Envato Forum": { + "errorType": "status_code", + "url": "https://forums.envato.com/u/{}", + "urlMain": "https://forums.envato.com/", + "username_claimed": "enabled" + }, + "Erome": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://www.erome.com/{}", + "urlMain": "https://www.erome.com/", + "username_claimed": "bob" + }, + "Exposure": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9-]{1,63}$", + "url": "https://{}.exposure.co/", + "urlMain": "https://exposure.co/", + "username_claimed": "jonasjacobsson" + }, + "exophase": { + "errorType": "status_code", + "url": "https://www.exophase.com/user/{}/", + "urlMain": "https://www.exophase.com/", + "username_claimed": "blue" + }, + "EyeEm": { + "errorType": "status_code", + "url": "https://www.eyeem.com/u/{}", + "urlMain": "https://www.eyeem.com/", + "username_claimed": "blue" + }, + "F3.cool": { + "errorType": "status_code", + "url": "https://f3.cool/{}/", + "urlMain": "https://f3.cool/", + "username_claimed": "blue" + }, + "Fameswap": { + "errorType": "status_code", + "url": "https://fameswap.com/user/{}", + "urlMain": "https://fameswap.com/", + "username_claimed": "fameswap" + }, + "Fandom": { + "errorType": "status_code", + "url": "https://www.fandom.com/u/{}", + "urlMain": "https://www.fandom.com/", + "username_claimed": "Jungypoo" + }, + "Fanpop": { + "errorType": "response_url", + "errorUrl": "https://www.fanpop.com/", + "url": "https://www.fanpop.com/fans/{}", + "urlMain": "https://www.fanpop.com/", + "username_claimed": "blue" + }, + "Finanzfrage": { + "errorType": "status_code", + "url": "https://www.finanzfrage.net/nutzer/{}", + "urlMain": "https://www.finanzfrage.net/", + "username_claimed": "finanzfrage" + }, + "Flickr": { + "errorType": "status_code", + "url": "https://www.flickr.com/people/{}", + "urlMain": "https://www.flickr.com/", + "username_claimed": "blue" + }, + "Flightradar24": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9_]{3,20}$", + "url": "https://my.flightradar24.com/{}", + "urlMain": "https://www.flightradar24.com/", + "username_claimed": "jebbrooks" + }, + "Flipboard": { + "errorType": "status_code", + "regexCheck": "^([a-zA-Z0-9_]){1,15}$", + "url": "https://flipboard.com/@{}", + "urlMain": "https://flipboard.com/", + "username_claimed": "blue" + }, + "Football": { + "errorMsg": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0441 \u0442\u0430\u043a\u0438\u043c \u0438\u043c\u0435\u043d\u0435\u043c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d", + "errorType": "message", + "url": "https://www.rusfootball.info/user/{}/", + "urlMain": "https://www.rusfootball.info/", + "username_claimed": "solo87" + }, + "FortniteTracker": { + "errorType": "status_code", + "url": "https://fortnitetracker.com/profile/all/{}", + "urlMain": "https://fortnitetracker.com/challenges", + "username_claimed": "blue" + }, + "Forum Ophilia": { + "errorMsg": "that user does not exist", + "errorType": "message", + "isNSFW": true, + "url": "https://www.forumophilia.com/profile.php?mode=viewprofile&u={}", + "urlMain": "https://www.forumophilia.com/", + "username_claimed": "bob" + }, + "Fosstodon": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9_]{1,30}$", + "url": "https://fosstodon.org/@{}", + "urlMain": "https://fosstodon.org/", + "username_claimed": "blue" + }, + "Framapiaf": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9_]{1,30}$", + "url": "https://framapiaf.org/@{}", + "urlMain": "https://framapiaf.org", + "username_claimed": "pylapp" + }, + "Freelancer": { + "errorMsg": "\"users\":{}", + "errorType": "message", + "url": "https://www.freelancer.com/u/{}", + "urlMain": "https://www.freelancer.com/", + "urlProbe": "https://www.freelancer.com/api/users/0.1/users?usernames%5B%5D={}&compact=true", + "username_claimed": "red0xff" + }, + "Freesound": { + "errorType": "status_code", + "url": "https://freesound.org/people/{}/", + "urlMain": "https://freesound.org/", + "username_claimed": "blue" + }, + "GNOME VCS": { + "errorType": "response_url", + "errorUrl": "https://gitlab.gnome.org/{}", + "regexCheck": "^(?!-)[a-zA-Z0-9_.-]{2,255}(? GIFs - Find & Share on GIPHY", + "url": "https://giphy.com/{}", + "urlMain": "https://giphy.com/", + "username_claimed": "red" + }, + "GitBook": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.gitbook.io/", + "urlMain": "https://gitbook.com/", + "username_claimed": "gitbook" + }, + "GitHub": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$", + "url": "https://www.github.com/{}", + "urlMain": "https://www.github.com/", + "username_claimed": "blue" + }, + "Warframe Market": { + "errorType": "status_code", + "request_method": "GET", + "url": "https://warframe.market/profile/{}", + "urlMain": "https://warframe.market/", + "urlProbe": "https://api.warframe.market/v2/user/{}", + "username_claimed": "kaiallalone" + }, + "GitLab": { + "errorMsg": "[]", + "errorType": "message", + "url": "https://gitlab.com/{}", + "urlMain": "https://gitlab.com/", + "urlProbe": "https://gitlab.com/api/v4/users?username={}", + "username_claimed": "blue" + }, + "Gitea": { + "errorType": "status_code", + "url": "https://gitea.com/{}", + "urlMain": "https://gitea.com/", + "username_claimed": "xorm" + }, + "Gitee": { + "errorType": "status_code", + "url": "https://gitee.com/{}", + "urlMain": "https://gitee.com/", + "username_claimed": "wizzer" + }, + "GoodReads": { + "errorType": "status_code", + "url": "https://www.goodreads.com/{}", + "urlMain": "https://www.goodreads.com/", + "username_claimed": "blue" + }, + "Google Play": { + "errorMsg": "the requested URL was not found on this server", + "errorType": "message", + "url": "https://play.google.com/store/apps/developer?id={}", + "urlMain": "https://play.google.com", + "username_claimed": "GitHub" + }, + "Gradle": { + "errorType": "status_code", + "regexCheck": "^(?!-)[a-zA-Z0-9-]{3,}(?User Not Found - Hive", + "errorType": "message", + "url": "https://hive.blog/@{}", + "urlMain": "https://hive.blog/", + "username_claimed": "mango-juice" + }, + "Holopin": { + "errorMsg": "true", + "errorType": "message", + "request_method": "POST", + "request_payload": { + "username": "{}" + }, + "url": "https://holopin.io/@{}", + "urlMain": "https://holopin.io", + "urlProbe": "https://www.holopin.io/api/auth/username", + "username_claimed": "red" + }, + "Houzz": { + "errorType": "status_code", + "url": "https://houzz.com/user/{}", + "urlMain": "https://houzz.com/", + "username_claimed": "blue" + }, + "HubPages": { + "errorType": "status_code", + "url": "https://hubpages.com/@{}", + "urlMain": "https://hubpages.com/", + "username_claimed": "blue" + }, + "Hubski": { + "errorMsg": "No such user", + "errorType": "message", + "url": "https://hubski.com/user/{}", + "urlMain": "https://hubski.com/", + "username_claimed": "blue" + }, + "HudsonRock": { + "errorMsg": "This username is not associated", + "errorType": "message", + "url": "https://cavalier.hudsonrock.com/api/json/v2/osint-tools/search-by-username?username={}", + "urlMain": "https://hudsonrock.com", + "username_claimed": "testadmin" + }, + "Hugging Face": { + "errorType": "status_code", + "url": "https://huggingface.co/{}", + "urlMain": "https://huggingface.co/", + "username_claimed": "Pasanlaksitha" + }, + "IFTTT": { + "errorType": "status_code", + "regexCheck": "^[A-Za-z0-9]{3,35}$", + "url": "https://www.ifttt.com/p/{}", + "urlMain": "https://www.ifttt.com/", + "username_claimed": "blue" + }, + "Ifunny": { + "errorType": "status_code", + "url": "https://ifunny.co/user/{}", + "urlMain": "https://ifunny.co/", + "username_claimed": "agua" + }, + "IRC-Galleria": { + "errorType": "response_url", + "errorUrl": "https://irc-galleria.net/users/search?username={}", + "url": "https://irc-galleria.net/user/{}", + "urlMain": "https://irc-galleria.net/", + "username_claimed": "appas" + }, + "Icons8 Community": { + "errorType": "status_code", + "url": "https://community.icons8.com/u/{}/summary", + "urlMain": "https://community.icons8.com/", + "username_claimed": "thefourCraft" + }, + "Image Fap": { + "errorMsg": "Not found", + "errorType": "message", + "isNSFW": true, + "url": "https://www.imagefap.com/profile/{}", + "urlMain": "https://www.imagefap.com/", + "username_claimed": "blue" + }, + "ImgUp.cz": { + "errorType": "status_code", + "url": "https://imgup.cz/{}", + "urlMain": "https://imgup.cz/", + "username_claimed": "adam" + }, + "Imgur": { + "errorType": "status_code", + "url": "https://imgur.com/user/{}", + "urlMain": "https://imgur.com/", + "urlProbe": "https://api.imgur.com/account/v1/accounts/{}?client_id=546c25a59c58ad7", + "username_claimed": "blue" + }, + "imood": { + "errorType": "status_code", + "url": "https://www.imood.com/users/{}", + "urlMain": "https://www.imood.com/", + "username_claimed": "blue" + }, + "Instagram": { + "errorType": "status_code", + "url": "https://instagram.com/{}", + "urlMain": "https://instagram.com/", + "urlProbe": "https://imginn.com/{}", + "username_claimed": "instagram" + }, + "Instapaper": { + "errorType": "status_code", + "request_method": "GET", + "url": "https://www.instapaper.com/p/{}", + "urlMain": "https://www.instapaper.com/", + "username_claimed": "john" + }, + "Instructables": { + "errorType": "status_code", + "url": "https://www.instructables.com/member/{}", + "urlMain": "https://www.instructables.com/", + "urlProbe": "https://www.instructables.com/json-api/showAuthorExists?screenName={}", + "username_claimed": "blue" + }, + "Intigriti": { + "errorType": "status_code", + "regexCheck": "[a-z0-9_]{1,25}", + "request_method": "GET", + "url": "https://app.intigriti.com/profile/{}", + "urlMain": "https://app.intigriti.com", + "urlProbe": "https://api.intigriti.com/user/public/profile/{}", + "username_claimed": "blue" + }, + "Ionic Forum": { + "errorType": "status_code", + "url": "https://forum.ionicframework.com/u/{}", + "urlMain": "https://forum.ionicframework.com/", + "username_claimed": "theblue222" + }, + "Issuu": { + "errorType": "status_code", + "url": "https://issuu.com/{}", + "urlMain": "https://issuu.com/", + "username_claimed": "jenny" + }, + "Itch.io": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.itch.io/", + "urlMain": "https://itch.io/", + "username_claimed": "blue" + }, + "Itemfix": { + "errorMsg": "ItemFix - Channel: ", + "errorType": "message", + "url": "https://www.itemfix.com/c/{}", + "urlMain": "https://www.itemfix.com/", + "username_claimed": "blue" + }, + "Jellyfin Weblate": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9@._-]{1,150}$", + "url": "https://translate.jellyfin.org/user/{}/", + "urlMain": "https://translate.jellyfin.org/", + "username_claimed": "EraYaN" + }, + "Jimdo": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.jimdosite.com", + "urlMain": "https://jimdosite.com/", + "username_claimed": "jenny" + }, + "Joplin Forum": { + "errorType": "status_code", + "url": "https://discourse.joplinapp.org/u/{}", + "urlMain": "https://discourse.joplinapp.org/", + "username_claimed": "laurent" + }, + "Jupyter Community Forum": { + "errorMsg": "Oops! That page doesn’t exist or is private.", + "errorType": "message", + "url": "https://discourse.jupyter.org/u/{}/summary", + "urlMain": "https://discourse.jupyter.org", + "username_claimed": "choldgraf" + }, + "Kaggle": { + "errorType": "status_code", + "url": "https://www.kaggle.com/{}", + "urlMain": "https://www.kaggle.com/", + "username_claimed": "dansbecker" + }, + "kaskus": { + "errorType": "status_code", + "url": "https://www.kaskus.co.id/@{}", + "urlMain": "https://www.kaskus.co.id", + "urlProbe": "https://www.kaskus.co.id/api/users?username={}", + "request_method": "GET", + "username_claimed": "l0mbart" + }, + "Keybase": { + "errorType": "status_code", + "url": "https://keybase.io/{}", + "urlMain": "https://keybase.io/", + "username_claimed": "blue" + }, + "Kick": { + "__comment__": "Cloudflare. Only viable when proxied.", + "errorType": "status_code", + "url": "https://kick.com/{}", + "urlMain": "https://kick.com/", + "urlProbe": "https://kick.com/api/v2/channels/{}", + "username_claimed": "blue" + }, + "Kik": { + "errorMsg": "The page you requested was not found", + "errorType": "message", + "url": "https://kik.me/{}", + "urlMain": "http://kik.me/", + "urlProbe": "https://ws2.kik.com/user/{}", + "username_claimed": "blue" + }, + "Kongregate": { + "errorType": "status_code", + "headers": { + "Accept": "text/html" + }, + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://www.kongregate.com/accounts/{}", + "urlMain": "https://www.kongregate.com/", + "username_claimed": "blue" + }, + "Kvinneguiden": { + "errorMsg": "{\"result\":\"ok\"}", + "errorType": "message", + "regexCheck": "^[a-zA-Z0-9_.-]{3,18}$", + "urlProbe": "https://forum.kvinneguiden.no/?app=core&module=system&controller=ajax&do=usernameExists&input={}", + "url": "https://forum.kvinneguiden.no", + "urlMain": "https://forum.kvinneguiden.no", + "username_claimed": "blue" + }, + "LOR": { + "errorType": "status_code", + "url": "https://www.linux.org.ru/people/{}/profile", + "urlMain": "https://linux.org.ru/", + "username_claimed": "red" + }, + "Laracast": { + "errorType": "status_code", + "url": "https://laracasts.com/@{}", + "urlMain": "https://laracasts.com/", + "regexCheck": "^[a-zA-Z0-9_-]{3,}$", + "username_claimed": "user1" + }, + "Launchpad": { + "errorType": "status_code", + "url": "https://launchpad.net/~{}", + "urlMain": "https://launchpad.net/", + "username_claimed": "blue" + }, + "LeetCode": { + "errorType": "status_code", + "url": "https://leetcode.com/{}", + "urlMain": "https://leetcode.com/", + "username_claimed": "blue" + }, + "LemmyWorld": { + "errorType": "message", + "errorMsg": "

Error!

", + "url": "https://lemmy.world/u/{}", + "urlMain": "https://lemmy.world", + "username_claimed": "blue" + }, + "LessWrong": { + "url": "https://www.lesswrong.com/users/{}", + "urlMain": "https://www.lesswrong.com/", + "errorType": "response_url", + "errorUrl": "https://www.lesswrong.com/", + "username_claimed": "habryka" + }, + "Letterboxd": { + "errorMsg": "Sorry, we can\u2019t find the page you\u2019ve requested.", + "errorType": "message", + "url": "https://letterboxd.com/{}", + "urlMain": "https://letterboxd.com/", + "username_claimed": "blue" + }, + "LibraryThing": { + "errorMsg": "

Error: This user doesn't exist

", + "errorType": "message", + "headers": { + "Cookie": "LTAnonSessionID=3159599315; LTUnifiedCookie=%7B%22areyouhuman%22%3A1%7D; " + }, + "url": "https://www.librarything.com/profile/{}", + "urlMain": "https://www.librarything.com/", + "username_claimed": "blue" + }, + "Lichess": { + "errorType": "status_code", + "url": "https://lichess.org/@/{}", + "urlMain": "https://lichess.org", + "username_claimed": "john" + }, + "LinkedIn": { + "errorType": "status_code", + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" + }, + "regexCheck": "^[a-zA-Z0-9]{3,100}$", + "request_method": "GET", + "url": "https://linkedin.com/in/{}", + "urlMain": "https://linkedin.com", + "username_claimed": "paulpfeister" + }, + "Linktree": { + "errorMsg": "\"statusCode\":404", + "errorType": "message", + "regexCheck": "^[\\w\\.]{2,30}$", + "url": "https://linktr.ee/{}", + "urlMain": "https://linktr.ee/", + "username_claimed": "anne" + }, + "LinuxFR.org": { + "errorType": "status_code", + "url": "https://linuxfr.org/users/{}", + "urlMain": "https://linuxfr.org/", + "username_claimed": "pylapp" + }, + "Listed": { + "errorType": "response_url", + "errorUrl": "https://listed.to/@{}", + "url": "https://listed.to/@{}", + "urlMain": "https://listed.to/", + "username_claimed": "listed" + }, + "LiveJournal": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://{}.livejournal.com", + "urlMain": "https://www.livejournal.com/", + "username_claimed": "blue" + }, + "Lobsters": { + "errorType": "status_code", + "regexCheck": "[A-Za-z0-9][A-Za-z0-9_-]{0,24}", + "url": "https://lobste.rs/u/{}", + "urlMain": "https://lobste.rs/", + "username_claimed": "jcs" + }, + "LottieFiles": { + "errorType": "status_code", + "url": "https://lottiefiles.com/{}", + "urlMain": "https://lottiefiles.com/", + "username_claimed": "lottiefiles" + }, + "LushStories": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://www.lushstories.com/profile/{}", + "urlMain": "https://www.lushstories.com/", + "username_claimed": "chris_brown" + }, + "MMORPG Forum": { + "errorType": "status_code", + "url": "https://forums.mmorpg.com/profile/{}", + "urlMain": "https://forums.mmorpg.com/", + "username_claimed": "goku" + }, + "Mamot": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9_]{1,30}$", + "url": "https://mamot.fr/@{}", + "urlMain": "https://mamot.fr/", + "username_claimed": "anciensEnssat" + }, + "Medium": { + "errorMsg": "Nitro Type | Competitive Typing Game | Race Your Friends", + "errorType": "message", + "url": "https://www.nitrotype.com/racer/{}", + "urlMain": "https://www.nitrotype.com/", + "username_claimed": "jianclash" + }, + "NotABug.org": { + "errorType": "status_code", + "url": "https://notabug.org/{}", + "urlMain": "https://notabug.org/", + "urlProbe": "https://notabug.org/{}/followers", + "username_claimed": "red" + }, + "Nothing Community": { + "errorType": "status_code", + "url": "https://nothing.community/u/{}", + "urlMain": "https://nothing.community/", + "username_claimed": "Carl" + }, + "Nyaa.si": { + "errorType": "status_code", + "url": "https://nyaa.si/user/{}", + "urlMain": "https://nyaa.si/", + "username_claimed": "blue" + }, + "ObservableHQ": { + "errorType": "message", + "errorMsg": "Page not found", + "url": "https://observablehq.com/@{}", + "urlMain": "https://observablehq.com/", + "username_claimed": "mbostock" + }, + "Open Collective": { + "errorType": "status_code", + "url": "https://opencollective.com/{}", + "urlMain": "https://opencollective.com/", + "username_claimed": "sindresorhus" + }, + "OpenGameArt": { + "errorType": "status_code", + "url": "https://opengameart.org/users/{}", + "urlMain": "https://opengameart.org", + "username_claimed": "ski" + }, + "OpenStreetMap": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://www.openstreetmap.org/user/{}", + "urlMain": "https://www.openstreetmap.org/", + "username_claimed": "blue" + }, + "Odysee": { + "errorMsg": "", + "errorType": "message", + "url": "https://odysee.com/@{}", + "urlMain": "https://odysee.com/", + "username_claimed": "Odysee" + }, + "Opensource": { + "errorType": "status_code", + "url": "https://opensource.com/users/{}", + "urlMain": "https://opensource.com/", + "username_claimed": "red" + }, + "OurDJTalk": { + "errorMsg": "The specified member cannot be found", + "errorType": "message", + "url": "https://ourdjtalk.com/members?username={}", + "urlMain": "https://ourdjtalk.com/", + "username_claimed": "steve" + }, + "Outgress": { + "errorMsg": "Outgress - Error", + "errorType": "message", + "url": "https://outgress.com/agents/{}", + "urlMain": "https://outgress.com/", + "username_claimed": "pylapp" + }, + "PCGamer": { + "errorMsg": "The specified member cannot be found. Please enter a member's entire name.", + "errorType": "message", + "url": "https://forums.pcgamer.com/members/?username={}", + "urlMain": "https://pcgamer.com", + "username_claimed": "admin" + }, + "PSNProfiles.com": { + "errorType": "response_url", + "errorUrl": "https://psnprofiles.com/?psnId={}", + "url": "https://psnprofiles.com/{}", + "urlMain": "https://psnprofiles.com/", + "username_claimed": "blue" + }, + "Packagist": { + "errorType": "response_url", + "errorUrl": "https://packagist.org/search/?q={}&reason=vendor_not_found", + "url": "https://packagist.org/packages/{}/", + "urlMain": "https://packagist.org/", + "username_claimed": "psr" + }, + "Pastebin": { + "errorMsg": "Not Found (#404)", + "errorType": "message", + "url": "https://pastebin.com/u/{}", + "urlMain": "https://pastebin.com/", + "username_claimed": "blue" + }, + "Patched": { + "errorMsg": "The member you specified is either invalid or doesn't exist.", + "errorType": "message", + "url": "https://patched.sh/User/{}", + "urlMain": "https://patched.sh/", + "username_claimed": "blue" + }, + "Patreon": { + "errorType": "status_code", + "url": "https://www.patreon.com/{}", + "urlMain": "https://www.patreon.com/", + "username_claimed": "blue" + }, + "PentesterLab": { + "errorType": "status_code", + "regexCheck": "^[\\w]{4,30}$", + "url": "https://pentesterlab.com/profile/{}", + "urlMain": "https://pentesterlab.com/", + "username_claimed": "0day" + }, + "HotUKdeals": { + "errorType": "status_code", + "url": "https://www.hotukdeals.com/profile/{}", + "urlMain": "https://www.hotukdeals.com/", + "username_claimed": "Blue", + "request_method": "GET" + }, + "Mydealz": { + "errorType": "status_code", + "url": "https://www.mydealz.de/profile/{}", + "urlMain": "https://www.mydealz.de/", + "username_claimed": "blue", + "request_method": "GET" + }, + "Chollometro": { + "errorType": "status_code", + "url": "https://www.chollometro.com/profile/{}", + "urlMain": "https://www.chollometro.com/", + "username_claimed": "blue", + "request_method": "GET" + }, + "PepperNL": { + "errorType": "status_code", + "url": "https://nl.pepper.com/profile/{}", + "urlMain": "https://nl.pepper.com/", + "username_claimed": "Dynaw", + "request_method": "GET" + }, + "PepperPL": { + "errorType": "status_code", + "url": "https://www.pepper.pl/profile/{}", + "urlMain": "https://www.pepper.pl/", + "username_claimed": "FireChicken", + "request_method": "GET" + }, + "Preisjaeger": { + "errorType": "status_code", + "url": "https://www.preisjaeger.at/profile/{}", + "urlMain": "https://www.preisjaeger.at/", + "username_claimed": "Stefan", + "request_method": "GET" + }, + "Pepperdeals": { + "errorType": "status_code", + "url": "https://www.pepperdeals.se/profile/{}", + "urlMain": "https://www.pepperdeals.se/", + "username_claimed": "Mark", + "request_method": "GET" + }, + "PepperealsUS": { + "errorType": "status_code", + "url": "https://www.pepperdeals.com/profile/{}", + "urlMain": "https://www.pepperdeals.com/", + "username_claimed": "Stepan", + "request_method": "GET" + }, + "Promodescuentos": { + "errorType": "status_code", + "url": "https://www.promodescuentos.com/profile/{}", + "urlMain": "https://www.promodescuentos.com/", + "username_claimed": "blue", + "request_method": "GET" + }, + "Periscope": { + "errorType": "status_code", + "url": "https://www.periscope.tv/{}/", + "urlMain": "https://www.periscope.tv/", + "username_claimed": "blue" + }, + "Pinkbike": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://www.pinkbike.com/u/{}/", + "urlMain": "https://www.pinkbike.com/", + "username_claimed": "blue" + }, + "pixelfed.social": { + "errorType": "status_code", + "url": "https://pixelfed.social/{}/", + "urlMain": "https://pixelfed.social", + "username_claimed": "pylapp" + }, + "PlayStore": { + "errorType": "status_code", + "url": "https://play.google.com/store/apps/developer?id={}", + "urlMain": "https://play.google.com/store", + "username_claimed": "Facebook" + }, + "Playstrategy": { + "errorType": "status_code", + "url": "https://playstrategy.org/@/{}", + "urlMain": "https://playstrategy.org", + "username_claimed": "oruro" + }, + "Plurk": { + "errorMsg": "User Not Found!", + "errorType": "message", + "url": "https://www.plurk.com/{}", + "urlMain": "https://www.plurk.com/", + "username_claimed": "plurkoffice" + }, + "PocketStars": { + "errorMsg": "Join Your Favorite Adult Stars", + "errorType": "message", + "isNSFW": true, + "url": "https://pocketstars.com/{}", + "urlMain": "https://pocketstars.com/", + "username_claimed": "hacker" + }, + "Pokemon Showdown": { + "errorType": "status_code", + "url": "https://pokemonshowdown.com/users/{}", + "urlMain": "https://pokemonshowdown.com", + "username_claimed": "blue" + }, + "Polarsteps": { + "errorType": "status_code", + "url": "https://polarsteps.com/{}", + "urlMain": "https://polarsteps.com/", + "urlProbe": "https://api.polarsteps.com/users/byusername/{}", + "username_claimed": "james" + }, + "Polygon": { + "errorType": "status_code", + "url": "https://www.polygon.com/users/{}", + "urlMain": "https://www.polygon.com/", + "username_claimed": "swiftstickler" + }, + "Polymart": { + "errorType": "response_url", + "errorUrl": "https://polymart.org/user/-1", + "url": "https://polymart.org/user/{}", + "urlMain": "https://polymart.org/", + "username_claimed": "craciu25yt" + }, + "Pornhub": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://pornhub.com/users/{}", + "urlMain": "https://pornhub.com/", + "username_claimed": "blue" + }, + "ProductHunt": { + "errorType": "status_code", + "url": "https://www.producthunt.com/@{}", + "urlMain": "https://www.producthunt.com/", + "username_claimed": "jenny" + }, + "programming.dev": { + "errorMsg": "Error!", + "errorType": "message", + "url": "https://programming.dev/u/{}", + "urlMain": "https://programming.dev", + "username_claimed": "pylapp" + }, + "Pychess": { + "errorType": "message", + "errorMsg": "404", + "url": "https://www.pychess.org/@/{}", + "urlMain": "https://www.pychess.org", + "username_claimed": "gbtami" + }, + "PromoDJ": { + "errorType": "status_code", + "url": "http://promodj.com/{}", + "urlMain": "http://promodj.com/", + "username_claimed": "blue" + }, + "Pronouns.page": { + "errorType": "status_code", + "url": "https://pronouns.page/@{}", + "urlMain": "https://pronouns.page/", + "username_claimed": "andrea" + }, + "PyPi": { + "errorType": "status_code", + "url": "https://pypi.org/user/{}", + "urlProbe": "https://pypi.org/_includes/administer-user-include/{}", + "urlMain": "https://pypi.org", + "username_claimed": "Blue" + }, + "Python.org Discussions": { + "errorMsg": "Oops! That page doesn’t exist or is private.", + "errorType": "message", + "url": "https://discuss.python.org/u/{}/summary", + "urlMain": "https://discuss.python.org", + "username_claimed": "pablogsal" + }, + "Rajce.net": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.rajce.idnes.cz/", + "urlMain": "https://www.rajce.idnes.cz/", + "username_claimed": "blue" + }, + "Rarible": { + "errorType": "status_code", + "url": "https://rarible.com/marketplace/api/v4/urls/{}", + "urlMain": "https://rarible.com/", + "username_claimed": "blue" + }, + "Rate Your Music": { + "errorType": "status_code", + "url": "https://rateyourmusic.com/~{}", + "urlMain": "https://rateyourmusic.com/", + "username_claimed": "blue" + }, + "Rclone Forum": { + "errorType": "status_code", + "url": "https://forum.rclone.org/u/{}", + "urlMain": "https://forum.rclone.org/", + "username_claimed": "ncw" + }, + "RedTube": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://www.redtube.com/users/{}", + "urlMain": "https://www.redtube.com/", + "username_claimed": "hacker" + }, + "Redbubble": { + "errorType": "status_code", + "url": "https://www.redbubble.com/people/{}", + "urlMain": "https://www.redbubble.com/", + "username_claimed": "blue" + }, + "Reddit": { + "errorMsg": "Sorry, nobody on Reddit goes by that name.", + "errorType": "message", + "headers": { + "accept-language": "en-US,en;q=0.9" + }, + "url": "https://www.reddit.com/user/{}", + "urlMain": "https://www.reddit.com/", + "username_claimed": "blue" + }, + "Realmeye": { + "errorMsg": "Sorry, but we either:", + "errorType": "message", + "url": "https://www.realmeye.com/player/{}", + "urlMain": "https://www.realmeye.com/", + "username_claimed": "rotmg" + }, + "Reisefrage": { + "errorType": "status_code", + "url": "https://www.reisefrage.net/nutzer/{}", + "urlMain": "https://www.reisefrage.net/", + "username_claimed": "reisefrage" + }, + "Replit.com": { + "errorType": "status_code", + "url": "https://replit.com/@{}", + "urlMain": "https://replit.com/", + "username_claimed": "blue" + }, + "ResearchGate": { + "errorType": "response_url", + "errorUrl": "https://www.researchgate.net/directory/profiles", + "regexCheck": "\\w+_\\w+", + "url": "https://www.researchgate.net/profile/{}", + "urlMain": "https://www.researchgate.net/", + "username_claimed": "John_Smith" + }, + "ReverbNation": { + "errorMsg": "Sorry, we couldn't find that page", + "errorType": "message", + "url": "https://www.reverbnation.com/{}", + "urlMain": "https://www.reverbnation.com/", + "username_claimed": "blue" + }, + "Roblox": { + "errorType": "status_code", + "url": "https://www.roblox.com/user.aspx?username={}", + "urlMain": "https://www.roblox.com/", + "username_claimed": "bluewolfekiller" + }, + "RocketTube": { + "errorMsg": "OOPS! Houston, we have a problem", + "errorType": "message", + "isNSFW": true, + "url": "https://www.rockettube.com/{}", + "urlMain": "https://www.rockettube.com/", + "username_claimed": "Tatteddick5600" + }, + "RoyalCams": { + "errorType": "status_code", + "url": "https://royalcams.com/profile/{}", + "urlMain": "https://royalcams.com", + "username_claimed": "asuna-black" + }, + "Ruby Forums": { + "errorMsg": "Oops! That page doesn’t exist or is private.", + "errorType": "message", + "url": "https://ruby-forum.com/u/{}/summary", + "urlMain": "https://ruby-forums.com", + "username_claimed": "rishard" + }, + "RubyGems": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]{1,40}", + "url": "https://rubygems.org/profiles/{}", + "urlMain": "https://rubygems.org/", + "username_claimed": "blue" + }, + "Rumble": { + "errorType": "status_code", + "url": "https://rumble.com/user/{}", + "urlMain": "https://rumble.com/", + "username_claimed": "John" + }, + "RuneScape": { + "errorMsg": "{\"error\":\"NO_PROFILE\",\"loggedIn\":\"false\"}", + "errorType": "message", + "regexCheck": "^(?! )[\\w -]{1,12}(?Page no longer exists", + "url": "https://slideshare.net/{}", + "urlMain": "https://slideshare.net/", + "username_claimed": "blue" + }, + "Slides": { + "errorCode": 204, + "errorType": "status_code", + "url": "https://slides.com/{}", + "urlMain": "https://slides.com/", + "username_claimed": "blue" + }, + "SmugMug": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z]{1,35}$", + "url": "https://{}.smugmug.com", + "urlMain": "https://smugmug.com", + "username_claimed": "winchester" + }, + "Smule": { + "errorMsg": "Smule | Page Not Found (404)", + "errorType": "message", + "url": "https://www.smule.com/{}", + "urlMain": "https://www.smule.com/", + "username_claimed": "blue" + }, + "Snapchat": { + "errorType": "status_code", + "regexCheck": "^[a-z][a-z-_.]{3,15}", + "request_method": "GET", + "url": "https://www.snapchat.com/add/{}", + "urlMain": "https://www.snapchat.com", + "username_claimed": "teamsnapchat" + }, + "SOOP": { + "errorType": "status_code", + "url": "https://www.sooplive.co.kr/station/{}", + "urlMain": "https://www.sooplive.co.kr/", + "urlProbe": "https://api-channel.sooplive.co.kr/v1.1/channel/{}/station", + "username_claimed": "udkn" + }, + "SoundCloud": { + "errorType": "status_code", + "url": "https://soundcloud.com/{}", + "urlMain": "https://soundcloud.com/", + "username_claimed": "blue" + }, + "SourceForge": { + "errorType": "status_code", + "url": "https://sourceforge.net/u/{}", + "urlMain": "https://sourceforge.net/", + "username_claimed": "blue" + }, + "SoylentNews": { + "errorMsg": "The user you requested does not exist, no matter how much you wish this might be the case.", + "errorType": "message", + "url": "https://soylentnews.org/~{}", + "urlMain": "https://soylentnews.org", + "username_claimed": "adam" + }, + "SpeakerDeck": { + "errorType": "status_code", + "url": "https://speakerdeck.com/{}", + "urlMain": "https://speakerdeck.com/", + "username_claimed": "pylapp" + }, + "Speedrun.com": { + "errorType": "status_code", + "url": "https://speedrun.com/users/{}", + "urlMain": "https://speedrun.com/", + "username_claimed": "example" + }, + "Spells8": { + "errorType": "status_code", + "url": "https://forum.spells8.com/u/{}", + "urlMain": "https://spells8.com", + "username_claimed": "susurrus" + }, + "Splice": { + "errorType": "status_code", + "url": "https://splice.com/{}", + "urlMain": "https://splice.com/", + "username_claimed": "splice" + }, + "Splits.io": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://splits.io/users/{}", + "urlMain": "https://splits.io", + "username_claimed": "cambosteve" + }, + "Sporcle": { + "errorType": "status_code", + "url": "https://www.sporcle.com/user/{}/people", + "urlMain": "https://www.sporcle.com/", + "username_claimed": "blue" + }, + "Sportlerfrage": { + "errorType": "status_code", + "url": "https://www.sportlerfrage.net/nutzer/{}", + "urlMain": "https://www.sportlerfrage.net/", + "username_claimed": "sportlerfrage" + }, + "SportsRU": { + "errorType": "status_code", + "url": "https://www.sports.ru/profile/{}/", + "urlMain": "https://www.sports.ru/", + "username_claimed": "blue" + }, + "Spotify": { + "errorType": "status_code", + "url": "https://open.spotify.com/user/{}", + "urlMain": "https://open.spotify.com/", + "username_claimed": "blue" + }, + "Star Citizen": { + "errorMsg": "404", + "errorType": "message", + "url": "https://robertsspaceindustries.com/citizens/{}", + "urlMain": "https://robertsspaceindustries.com/", + "username_claimed": "blue" + }, + "Status Cafe": { + "errorMsg": "Page Not Found", + "errorType": "message", + "url": "https://status.cafe/users/{}", + "urlMain": "https://status.cafe/", + "username_claimed": "blue" + }, + "Steam Community (Group)": { + "errorMsg": "No group could be retrieved for the given URL", + "errorType": "message", + "url": "https://steamcommunity.com/groups/{}", + "urlMain": "https://steamcommunity.com/", + "username_claimed": "blue" + }, + "Steam Community (User)": { + "errorMsg": "The specified profile could not be found", + "errorType": "message", + "url": "https://steamcommunity.com/id/{}/", + "urlMain": "https://steamcommunity.com/", + "username_claimed": "blue" + }, + "Strava": { + "errorType": "status_code", + "regexCheck": "^[^.]*?$", + "url": "https://www.strava.com/athletes/{}", + "urlMain": "https://www.strava.com/", + "username_claimed": "blue" + }, + "SublimeForum": { + "errorType": "status_code", + "url": "https://forum.sublimetext.com/u/{}", + "urlMain": "https://forum.sublimetext.com/", + "username_claimed": "blue" + }, + "TETR.IO": { + "errorMsg": "No such user!", + "errorType": "message", + "url": "https://ch.tetr.io/u/{}", + "urlMain": "https://tetr.io", + "urlProbe": "https://ch.tetr.io/api/users/{}", + "username_claimed": "osk" + }, + "TheMovieDB": { + "errorType": "status_code", + "url": "https://www.themoviedb.org/u/{}", + "urlMain": "https://www.themoviedb.org/", + "username_claimed": "blue" + }, + "TikTok": { + "url": "https://www.tiktok.com/@{}", + "urlMain": "https://www.tiktok.com", + "errorType": "message", + "errorMsg": [ + "\"statusCode\":10221", + "Govt. of India decided to block 59 apps" + ], + "username_claimed": "charlidamelio" + }, + "Tiendanube": { + "url": "https://{}.mitiendanube.com/", + "urlMain": "https://www.tiendanube.com/", + "errorType": "status_code", + "username_claimed": "blue" + }, + "Topcoder": { + "errorType": "status_code", + "url": "https://profiles.topcoder.com/{}/", + "urlMain": "https://topcoder.com/", + "username_claimed": "USER", + "urlProbe": "https://api.topcoder.com/v5/members/{}", + "regexCheck": "^[a-zA-Z0-9_.]+$" + }, + "Topmate": { + "errorType": "status_code", + "url": "https://topmate.io/{}", + "urlMain": "https://topmate.io/", + "username_claimed": "blue" + }, + "TRAKTRAIN": { + "errorType": "status_code", + "url": "https://traktrain.com/{}", + "urlMain": "https://traktrain.com/", + "username_claimed": "traktrain" + }, + "Telegram": { + "errorMsg": [ + "Telegram Messenger", + "If you have Telegram, you can contact User ", + "429 Too Many Requests" + ], + "errorType": "message", + "regexCheck": "^[a-zA-Z0-9_]{1,15}$", + "url": "https://x.com/{}", + "urlMain": "https://x.com/", + "urlProbe": "https://nitter.privacydev.net/{}", + "username_claimed": "blue" + }, + "Typeracer": { + "errorMsg": "Profile Not Found", + "errorType": "message", + "url": "https://data.typeracer.com/pit/profile?user={}", + "urlMain": "https://typeracer.com", + "username_claimed": "blue" + }, + "Ultimate-Guitar": { + "errorType": "status_code", + "url": "https://ultimate-guitar.com/u/{}", + "urlMain": "https://ultimate-guitar.com/", + "username_claimed": "blue" + }, + "Unsplash": { + "errorType": "status_code", + "regexCheck": "^[a-z0-9_]{1,60}$", + "url": "https://unsplash.com/@{}", + "urlMain": "https://unsplash.com/", + "username_claimed": "jenny" + }, + "Untappd": { + "errorType": "status_code", + "url": "https://untappd.com/user/{}", + "urlMain": "https://untappd.com/", + "username_claimed": "untappd" + }, + "Valorant Forums": { + "errorMsg": "The page you requested could not be found.", + "errorType": "message", + "url": "https://valorantforums.com/u/{}", + "urlMain": "https://valorantforums.com", + "username_claimed": "Wolves" + }, + "VK": { + "errorType": "response_url", + "errorUrl": "https://www.quora.com/profile/{}", + "url": "https://vk.com/{}", + "urlMain": "https://vk.com/", + "username_claimed": "brown" + }, + "VSCO": { + "errorType": "status_code", + "url": "https://vsco.co/{}", + "urlMain": "https://vsco.co/", + "username_claimed": "blue" + }, + "Velog": { + "errorType": "status_code", + "url": "https://velog.io/@{}/posts", + "urlMain": "https://velog.io/", + "username_claimed": "qlgks1" + }, + "Velomania": { + "errorMsg": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u043d\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d \u0438 \u043d\u0435 \u0438\u043c\u0435\u0435\u0442 \u043f\u0440\u043e\u0444\u0438\u043b\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430.", + "errorType": "message", + "url": "https://forum.velomania.ru/member.php?username={}", + "urlMain": "https://forum.velomania.ru/", + "username_claimed": "red" + }, + "Venmo": { + "errorMsg": ["Venmo | Page Not Found"], + "errorType": "message", + "headers": { + "Host": "account.venmo.com" + }, + "url": "https://account.venmo.com/u/{}", + "urlMain": "https://venmo.com/", + "urlProbe": "https://test1.venmo.com/u/{}", + "username_claimed": "jenny" + }, + "Vero": { + "errorMsg": "Not Found", + "errorType": "message", + "request_method": "GET", + "url": "https://vero.co/{}", + "urlMain": "https://vero.co/", + "username_claimed": "blue" + }, + "Vimeo": { + "errorType": "status_code", + "url": "https://vimeo.com/{}", + "urlMain": "https://vimeo.com/", + "username_claimed": "blue" + }, + "VirusTotal": { + "errorType": "status_code", + "request_method": "GET", + "url": "https://www.virustotal.com/gui/user/{}", + "urlMain": "https://www.virustotal.com/", + "urlProbe": "https://www.virustotal.com/ui/users/{}/avatar", + "username_claimed": "blue" + }, + "VLR": { + "errorType": "status_code", + "url": "https://www.vlr.gg/user/{}", + "urlMain": "https://www.vlr.gg", + "username_claimed": "optms" + }, + "WICG Forum": { + "errorType": "status_code", + "regexCheck": "^(?![.-])[a-zA-Z0-9_.-]{3,20}$", + "url": "https://discourse.wicg.io/u/{}/summary", + "urlMain": "https://discourse.wicg.io/", + "username_claimed": "stefano" + }, + "Wakatime": { + "errorType": "status_code", + "url": "https://wakatime.com/@{}", + "urlMain": "https://wakatime.com/", + "username_claimed": "blue" + }, + "Warrior Forum": { + "errorType": "status_code", + "url": "https://www.warriorforum.com/members/{}.html", + "urlMain": "https://www.warriorforum.com/", + "username_claimed": "blue" + }, + "Wattpad": { + "errorType": "status_code", + "url": "https://www.wattpad.com/user/{}", + "urlMain": "https://www.wattpad.com/", + "urlProbe": "https://www.wattpad.com/api/v3/users/{}/", + "username_claimed": "Dogstho7951" + }, + "WebNode": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.webnode.cz/", + "urlMain": "https://www.webnode.cz/", + "username_claimed": "radkabalcarova" + }, + "Weblate": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9@._-]{1,150}$", + "url": "https://hosted.weblate.org/user/{}/", + "urlMain": "https://hosted.weblate.org/", + "username_claimed": "adam" + }, + "Weebly": { + "errorType": "status_code", + "regexCheck": "^[a-zA-Z0-9-]{1,63}$", + "url": "https://{}.weebly.com/", + "urlMain": "https://weebly.com/", + "username_claimed": "blue" + }, + "Wikidot": { + "errorMsg": "User does not exist.", + "errorType": "message", + "url": "http://www.wikidot.com/user:info/{}", + "urlMain": "http://www.wikidot.com/", + "username_claimed": "blue" + }, + "Wikipedia": { + "errorMsg": "centralauth-admin-nonexistent:", + "errorType": "message", + "url": "https://en.wikipedia.org/wiki/Special:CentralAuth/{}?uselang=qqx", + "urlMain": "https://www.wikipedia.org/", + "username_claimed": "Hoadlck" + }, + "Windy": { + "errorType": "status_code", + "url": "https://community.windy.com/user/{}", + "urlMain": "https://windy.com/", + "username_claimed": "blue" + }, + "Wix": { + "errorType": "status_code", + "regexCheck": "^[\\w@-]+?$", + "url": "https://{}.wix.com", + "urlMain": "https://wix.com/", + "username_claimed": "support" + }, + "WolframalphaForum": { + "errorType": "status_code", + "url": "https://community.wolfram.com/web/{}/home", + "urlMain": "https://community.wolfram.com/", + "username_claimed": "unico" + }, + "WordPress": { + "errorType": "response_url", + "errorUrl": "wordpress.com/typo/?subdomain=", + "regexCheck": "^[a-zA-Z][a-zA-Z0-9_-]*$", + "url": "https://{}.wordpress.com/", + "urlMain": "https://wordpress.com", + "username_claimed": "blue" + }, + "WordPressOrg": { + "errorType": "response_url", + "errorUrl": "https://wordpress.org", + "url": "https://profiles.wordpress.org/{}/", + "urlMain": "https://wordpress.org/", + "username_claimed": "blue" + }, + "Wordnik": { + "errorMsg": "Page Not Found", + "errorType": "message", + "regexCheck": "^[a-zA-Z0-9_.+-]{1,40}$", + "url": "https://www.wordnik.com/users/{}", + "urlMain": "https://www.wordnik.com/", + "username_claimed": "blue" + }, + "Wykop": { + "errorType": "status_code", + "url": "https://www.wykop.pl/ludzie/{}", + "urlMain": "https://www.wykop.pl", + "username_claimed": "blue" + }, + "Xbox Gamertag": { + "errorType": "status_code", + "url": "https://xboxgamertag.com/search/{}", + "urlMain": "https://xboxgamertag.com/", + "username_claimed": "red" + }, + "Xvideos": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://xvideos.com/profiles/{}", + "urlMain": "https://xvideos.com/", + "username_claimed": "blue" + }, + "YandexMusic": { + "__comment__": "The first and third errorMsg relate to geo-restrictions and bot detection/captchas.", + "errorMsg": [ + "\u041e\u0448\u0438\u0431\u043a\u0430 404", + "Threads • Log in", + "errorType": "message", + "headers": { + "Sec-Fetch-Mode": "navigate" + }, + "url": "https://www.threads.net/@{}", + "urlMain": "https://www.threads.net/", + "username_claimed": "zuck" + }, + "toster": { + "errorType": "status_code", + "url": "https://www.toster.ru/user/{}/answers", + "urlMain": "https://www.toster.ru/", + "username_claimed": "adam" + }, + "tumblr": { + "errorType": "status_code", + "url": "https://{}.tumblr.com/", + "urlMain": "https://www.tumblr.com/", + "username_claimed": "goku" + }, + "uid": { + "errorType": "status_code", + "url": "http://uid.me/{}", + "urlMain": "https://uid.me/", + "username_claimed": "blue" + }, + "write.as": { + "errorType": "status_code", + "url": "https://write.as/{}", + "urlMain": "https://write.as", + "username_claimed": "pylapp" + }, + "xHamster": { + "errorType": "status_code", + "isNSFW": true, + "url": "https://xhamster.com/users/{}", + "urlMain": "https://xhamster.com", + "urlProbe": "https://xhamster.com/users/{}?old_browser=true", + "username_claimed": "blue" + }, + "znanylekarz.pl": { + "errorType": "status_code", + "url": "https://www.znanylekarz.pl/{}", + "urlMain": "https://znanylekarz.pl", + "username_claimed": "janusz-nowak" + }, + "Platzi": { + "errorType": "status_code", + "errorCode": 404, + "url": "https://platzi.com/p/{}/", + "urlMain": "https://platzi.com/", + "username_claimed": "freddier", + "request_method": "GET" + }, + "BabyRu": { + "url": "https://www.baby.ru/u/{}", + "urlMain": "https://www.baby.ru/", + "errorType": "message", + "errorMsg": [ + "\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u0430, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u0432\u044b \u0438\u0441\u043a\u0430\u043b\u0438, \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430", + "\u0414\u043e\u0441\u0442\u0443\u043f \u0441 \u0432\u0430\u0448\u0435\u0433\u043e IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d" + ], + "username_claimed": "example" + }, + "Wowhead": { + "url": "https://wowhead.com/user={}", + "urlMain": "https://wowhead.com/", + "errorType": "status_code", + "errorCode": 404, + "username_claimed": "blue" + }, + "addons.wago.io": { + "url": "https://addons.wago.io/user/{}", + "urlMain": "https://addons.wago.io/", + "errorType": "status_code", + "errorCode": 404, + "username_claimed": "blue" + }, + "CurseForge": { + "url": "https://www.curseforge.com/members/{}/projects", + "urlMain": "https://www.curseforge.com.", + "errorType": "status_code", + "errorCode": 404, + "username_claimed": "blue" + } +} From 87b93c1acc7b944bdfcb0a282e65499edc82d0dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 09:05:56 +0000 Subject: [PATCH 04/17] Add filters, pagination, rate limiting, business category, and UX polish Features: - Business category: LinkedIn, ProductHunt, Patreon, Gumroad, Ko-fi, BuyMeACoffee, Freelancer, Trello, Wix - Rate limiting: 5 lookups/day for unauthenticated (server-side, in-memory per IP); unlimited for Clerk auth users - /api/lookup/status endpoint returns remaining lookups + auth state - Availability filter dropdown (All / Available / Taken / Unknown) via nuqs ?status= - Platform name search input with 250ms throttle via nuqs ?search= - shadcn Pagination (12 cards/page) with ellipsis for many pages, via nuqs ?page= - shadcn Progress bar (h-0.5, fades out when done) - Filter toolbar: search + status select + limit badge in one row; category pills in row below - Category pills show live result counts as checks stream in - All filters (category, status, search, page) work together seamlessly; page resets to 1 on filter change - Smooth handle transition: grid remounts with fade-in when q changes (keyed div) - Fetch+AbortController replaces EventSource for proper 429 detection - Hero: restored DecorIcon corner marks + FullWidthDivider at bottom - Cards only appear after a handle is entered (no default preview grid) - Micro-interactions: card hover lift + shadow, button ExternalLink opacity, pill active:scale-95, smooth transitions https://claude.ai/code/session_011nbTf3SpFDiLh1LkHfrxow --- src/app/api/lookup/route.ts | 28 +- src/app/api/lookup/status/route.ts | 25 ++ src/components/hero.tsx | 20 +- src/components/lookup-results.tsx | 635 ++++++++++++++++++++++------- src/lib/platforms.ts | 73 ++-- src/lib/rate-limit.ts | 37 ++ 6 files changed, 608 insertions(+), 210 deletions(-) create mode 100644 src/app/api/lookup/status/route.ts create mode 100644 src/lib/rate-limit.ts diff --git a/src/app/api/lookup/route.ts b/src/app/api/lookup/route.ts index 1e0d0ef..549eb04 100644 --- a/src/app/api/lookup/route.ts +++ b/src/app/api/lookup/route.ts @@ -1,11 +1,21 @@ import { type NextRequest } from "next/server"; +import { auth } from "@clerk/nextjs/server"; import { checkAllPlatforms } from "@/lib/lookup"; +import { getRateLimitInfo, incrementUsage } from "@/lib/rate-limit"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const HANDLE_REGEX = /^[a-zA-Z0-9_.-]{1,50}$/; +function getClientIp(req: NextRequest): string { + return ( + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "127.0.0.1" + ); +} + export async function GET(request: NextRequest) { const handle = request.nextUrl.searchParams.get("handle")?.trim(); @@ -13,15 +23,27 @@ export async function GET(request: NextRequest) { return Response.json({ error: "Invalid handle" }, { status: 400 }); } + const { userId } = await auth(); + + if (!userId) { + const ip = getClientIp(request); + const { allowed } = getRateLimitInfo(ip); + if (!allowed) { + return Response.json( + { error: "Daily limit reached", code: "RATE_LIMITED" }, + { status: 429 } + ); + } + incrementUsage(ip); + } + const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (data: object) => { try { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(data)}\n\n`) - ); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); } catch { // Client disconnected } diff --git a/src/app/api/lookup/status/route.ts b/src/app/api/lookup/status/route.ts new file mode 100644 index 0000000..c678679 --- /dev/null +++ b/src/app/api/lookup/status/route.ts @@ -0,0 +1,25 @@ +import { type NextRequest } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { getRateLimitInfo } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +function getClientIp(req: NextRequest): string { + return ( + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "127.0.0.1" + ); +} + +export async function GET(request: NextRequest) { + const { userId } = await auth(); + + if (userId) { + return Response.json({ authenticated: true, unlimited: true }); + } + + const ip = getClientIp(request); + const { remaining, limit, allowed } = getRateLimitInfo(ip); + return Response.json({ authenticated: false, remaining, limit, allowed }); +} diff --git a/src/components/hero.tsx b/src/components/hero.tsx index 6f8ac52..75cebac 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { DecorIcon } from "@/components/ui/decor-icon"; import { FullWidthDivider } from "@/components/ui/full-width-divider"; import { LookupForm } from "@/components/lookup-form"; @@ -6,11 +7,8 @@ export function HeroSection() { return (
- {/* X Faded Borders & Shades */} -
); } diff --git a/src/components/lookup-results.tsx b/src/components/lookup-results.tsx index 5f489a2..852eb94 100644 --- a/src/components/lookup-results.tsx +++ b/src/components/lookup-results.tsx @@ -1,17 +1,37 @@ "use client"; -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import Link from "next/link"; import { ExternalLink, CheckCircle2, XCircle, HelpCircle, - Loader2, + Search, + ChevronDown, } from "lucide-react"; -import { useQueryState } from "nuqs"; +import { useQueryState, parseAsInteger } from "nuqs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Spinner } from "@/components/ui/spinner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; import { Card, CardContent, @@ -24,76 +44,130 @@ import { cn } from "@/lib/utils"; import { CATEGORIES, PLATFORMS, type Category } from "@/lib/platforms"; import type { PlatformResult } from "@/lib/lookup"; -const STATUS = { +// ── Constants ────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 12; + +const STATUS_CFG = { available: { label: "Available", icon: CheckCircle2, badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + dot: "bg-emerald-500", }, taken: { label: "Taken", icon: XCircle, badge: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400", + dot: "bg-red-500", }, unknown: { label: "Unknown", icon: HelpCircle, badge: "text-muted-foreground", + dot: "bg-muted-foreground/40", }, } as const; -function CategoryFilter({ - active, - counts, - onChange, +// ── Types ────────────────────────────────────────────────────────────────── + +interface LimitInfo { + authenticated: boolean; + unlimited?: boolean; + remaining?: number; + limit?: number; +} + +// ── Pagination helper ────────────────────────────────────────────────────── + +function getPageNumbers(current: number, total: number): (number | "ellipsis")[] { + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); + const pages: (number | "ellipsis")[] = [1]; + if (current > 3) pages.push("ellipsis"); + for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) { + pages.push(i); + } + if (current < total - 2) pages.push("ellipsis"); + pages.push(total); + return pages; +} + +// ── Sub-components ───────────────────────────────────────────────────────── + +function LimitBadge({ info }: { info: LimitInfo | null }) { + if (!info || info.unlimited) return null; + const { remaining = 0, limit = 5 } = info; + const isEmpty = remaining === 0; + return ( + + + {isEmpty ? ( + <> + Limit reached ·{" "} + + Sign in + + + ) : ( + `${remaining}/${limit} lookups today` + )} + + ); +} + +function StatsBar({ + results, + total, + done, }: { - active: string; - counts: Record; - onChange: (cat: string) => void; + results: PlatformResult[]; + total: number; + done: boolean; }) { + const available = results.filter((r) => r.status === "available").length; + const taken = results.filter((r) => r.status === "taken").length; + return ( -
- {CATEGORIES.map((cat) => { - const count = counts[cat.id] ?? 0; - const isActive = active === cat.id; - return ( - - ); - })} +
+ {!done ? ( + + + {results.length} / {total} + + ) : ( + {results.length} checked + )} + {available > 0 && ( + + {available} available + + )} + {taken > 0 && ( + {taken} taken + )}
); } function ResultCard({ result, index }: { result: PlatformResult; index: number }) { - const cfg = STATUS[result.status]; + const cfg = STATUS_CFG[result.status]; const Icon = cfg.icon; + return (
- + {result.platform} @@ -103,14 +177,19 @@ function ResultCard({ result, index }: { result: PlatformResult; index: number } - -

{result.url}

+ +

{result.url}

- @@ -122,16 +201,16 @@ function ResultCard({ result, index }: { result: PlatformResult; index: number } function SkeletonCard({ index }: { index: number }) { return (
-
-
+
+
- -
+ +
@@ -141,133 +220,377 @@ function SkeletonCard({ index }: { index: number }) { ); } -function StatsBar({ results, total, done }: { results: PlatformResult[]; total: number; done: boolean }) { - const available = results.filter((r) => r.status === "available").length; - const taken = results.filter((r) => r.status === "taken").length; - return ( -
- {!done ? ( - - - Checking {results.length} / {total} platforms - - ) : ( - {results.length} platforms checked - )} - {available > 0 && ( - {available} available - )} - {taken > 0 && ( - {taken} taken - )} -
- ); -} - -function DefaultGrid() { - const featured = PLATFORMS.filter((p) => p.category === "featured"); - return ( -
- {featured.map((p, i) => ( -
- - - {p.name} - - -

{p.urlMain}

-
- -
- - -
- ))} -
- ); -} +// ── Main component ───────────────────────────────────────────────────────── export function LookupResults() { + // URL state (all filters in URL for shareability) const [q] = useQueryState("q", { defaultValue: "" }); - const [category, setCategory] = useQueryState("category", { defaultValue: "featured" }); + const [category, setCategory] = useQueryState("category", { + defaultValue: "featured", + clearOnDefault: false, + }); + const [status, setStatus] = useQueryState("status", { + defaultValue: "all", + clearOnDefault: false, + }); + const [search, setSearch] = useQueryState("search", { + defaultValue: "", + clearOnDefault: true, + throttleMs: 250, + }); + const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); + + // Local state const [results, setResults] = useState([]); const [done, setDone] = useState(false); - const esRef = useRef(null); - - const startLookup = useCallback((handle: string) => { - esRef.current?.close(); - setResults([]); - setDone(false); - const es = new EventSource(`/api/lookup?handle=${encodeURIComponent(handle)}`); - esRef.current = es; - es.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as PlatformResult & { done?: boolean }; - if (data.done) { setDone(true); es.close(); return; } - setResults((prev) => [...prev, data]); - } catch { /* ignore */ } - }; - es.onerror = () => { setDone(true); es.close(); }; + const [rateLimited, setRateLimited] = useState(false); + const [limitInfo, setLimitInfo] = useState(null); + const [lookupKey, setLookupKey] = useState(0); + const abortRef = useRef(null); + + // Fetch rate limit status + const fetchLimitInfo = useCallback(() => { + fetch("/api/lookup/status") + .then((r) => r.json()) + .then(setLimitInfo) + .catch(() => {}); }, []); useEffect(() => { - if (!q) { esRef.current?.close(); setResults([]); setDone(false); return; } + fetchLimitInfo(); + }, [fetchLimitInfo]); + + // Start lookup using fetch+streams (gives us HTTP status access) + const startLookup = useCallback( + async (handle: string) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setResults([]); + setDone(false); + setRateLimited(false); + + try { + const response = await fetch( + `/api/lookup?handle=${encodeURIComponent(handle)}`, + { signal: controller.signal } + ); + + if (response.status === 429) { + setRateLimited(true); + setDone(true); + fetchLimitInfo(); + return; + } + + if (!response.ok || !response.body) { + setDone(true); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done: streamDone, value } = await reader.read(); + if (streamDone) break; + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + + for (const chunk of chunks) { + if (!chunk.startsWith("data: ")) continue; + try { + const data = JSON.parse(chunk.slice(6)) as PlatformResult & { done?: boolean }; + if (data.done) { + setDone(true); + fetchLimitInfo(); + return; + } + setResults((prev) => [...prev, data]); + } catch { + // ignore parse errors + } + } + } + + setDone(true); + } catch (err) { + if (!(err instanceof DOMException && err.name === "AbortError")) { + setDone(true); + } + } + }, + [fetchLimitInfo] + ); + + // Trigger lookup when q changes + useEffect(() => { + if (!q) { + abortRef.current?.abort(); + setResults([]); + setDone(false); + setRateLimited(false); + return; + } + setLookupKey((k) => k + 1); + setPage(1); startLookup(q); - return () => esRef.current?.close(); - }, [q, startLookup]); + return () => abortRef.current?.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [q]); - const counts = results.reduce>((acc, r) => { - acc.all = (acc.all ?? 0) + 1; - acc[r.category] = (acc[r.category] ?? 0) + 1; - return acc; - }, {}); + // Reset page when filters change + const handleCategory = (v: string) => { setCategory(v); setPage(1); }; + const handleStatus = (v: string) => { setStatus(v); setPage(1); }; + const handleSearch = (v: string) => { setSearch(v); setPage(1); }; + + // Combined filtering + sorting + const filtered = useMemo(() => { + let list = [...results]; + + const cat = category ?? "featured"; + if (cat !== "all") list = list.filter((r) => r.category === cat); + + const st = status ?? "all"; + if (st !== "all") list = list.filter((r) => r.status === st); + + const s = (search ?? "").toLowerCase().trim(); + if (s) list = list.filter((r) => r.platform.toLowerCase().includes(s)); + + // Sort: available → taken → unknown, then alphabetical + list.sort((a, b) => { + const order = { available: 0, taken: 1, unknown: 2 }; + const diff = order[a.status] - order[b.status]; + return diff !== 0 ? diff : a.platform.localeCompare(b.platform); + }); + + return list; + }, [results, category, status, search]); - const activeCat = (category ?? "featured") as Category | "all"; - const filtered = activeCat === "all" ? results : results.filter((r) => r.category === activeCat); const totalPlatforms = PLATFORMS.length; - const categoryTotal = activeCat === "all" ? totalPlatforms : PLATFORMS.filter((p) => p.category === activeCat).length; - const skeletonCount = !done ? Math.max(0, categoryTotal - filtered.length) : 0; + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const currentPage = Math.min(Math.max(1, page ?? 1), totalPages); + const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); - if (!q) { + // Skeleton fill: show up to PAGE_SIZE skeletons while loading on page 1 + const skeletonCount = + !done && currentPage === 1 ? Math.max(0, PAGE_SIZE - paginated.length) : 0; + + // Per-category result counts for pill badges + const catCounts = useMemo( + () => + results.reduce>((acc, r) => { + acc.all = (acc.all ?? 0) + 1; + acc[r.category] = (acc[r.category] ?? 0) + 1; + return acc; + }, {}), + [results] + ); + + // ── Nothing to show ────────────────────────────────────────────────────── + if (!q) return null; + + // ── Rate limited ───────────────────────────────────────────────────────── + if (rateLimited) { return ( -
-

- Enter a handle above — we'll check availability across {totalPlatforms} platforms instantly. +

+

+ Daily limit reached.{" "} + + Sign in + {" "} + for unlimited lookups.

-
); } + const activeCat = (category ?? "featured") as Category | "all"; + const activeStatus = status ?? "all"; + return (
-
-

@{q}

-
+ + {/* Handle + stats */} +
+

+ @{q} +

+
- {!done && ( -
-
+ + {/* Progress bar */} +
+ +
+ + {/* ── Filter toolbar ── */} +
+ {/* Row 1: search + status + limit */} +
+ {/* Platform search */} +
+ + handleSearch(e.target.value)} + /> +
+ + {/* Availability filter */} + + + {/* Lookup limit badge */} + +
+ + {/* Row 2: category pills */} +
+ {CATEGORIES.map((cat) => { + const count = catCounts[cat.id] ?? 0; + const isActive = activeCat === cat.id; + return ( + + ); + })}
- )} -
- setCategory(cat)} />
-
- {filtered.map((result, i) => ( - - ))} - {Array.from({ length: skeletonCount }, (_, i) => ( - - ))} + + {/* ── Results grid (keyed for clean transition on new search) ── */} +
+
+ {paginated.map((result, i) => ( + + ))} + {Array.from({ length: skeletonCount }, (_, i) => ( + + ))} +
+ + {/* Empty state (done + no results for current filter) */} + {done && filtered.length === 0 && ( +
+

+ No platforms match your filters. +

+ +
+ )}
+ + {/* ── Pagination ── */} + {totalPages > 1 && ( +
+ + + + setPage(Math.max(1, currentPage - 1))} + className={cn( + "cursor-pointer select-none transition-opacity", + currentPage <= 1 && "pointer-events-none opacity-40" + )} + /> + + + {getPageNumbers(currentPage, totalPages).map((p, i) => + p === "ellipsis" ? ( + + + + ) : ( + + setPage(p)} + className="cursor-pointer select-none transition-colors" + > + {p} + + + ) + )} + + + setPage(Math.min(totalPages, currentPage + 1))} + className={cn( + "cursor-pointer select-none transition-opacity", + currentPage >= totalPages && "pointer-events-none opacity-40" + )} + /> + + + +
+ )}
); } diff --git a/src/lib/platforms.ts b/src/lib/platforms.ts index c5ffba5..75f5f0f 100644 --- a/src/lib/platforms.ts +++ b/src/lib/platforms.ts @@ -9,6 +9,7 @@ export type Category = | "creative" | "music" | "writing" + | "business" | "other"; export interface Platform { @@ -29,6 +30,19 @@ export interface Platform { // ── Category Rules ────────────────────────────────────────────────────────── +// Business-first: platforms every new business founder should claim +const BUSINESS = new Set([ + "LinkedIn", + "ProductHunt", + "Patreon", + "Gumroad", + "kofi", + "BuyMeACoffee", + "Freelancer", + "Trello", + "Wix", +]); + const FEATURED = new Set([ "GitHub", "Instagram", @@ -36,7 +50,6 @@ const FEATURED = new Set([ "TikTok", "YouTube", "Reddit", - "LinkedIn", "Snapchat", "Pinterest", "Twitch", @@ -48,7 +61,6 @@ const FEATURED = new Set([ "Spotify", "Behance", "Dribbble", - "Patreon", "DeviantArt", "HackerNews", "last.fm", @@ -57,7 +69,6 @@ const FEATURED = new Set([ "Bluesky", "Codepen", "Linktree", - "ProductHunt", "npm", ]); @@ -98,6 +109,7 @@ const WRITING_KEYS = [ ]; function getCategory(name: string, urlMain: string): Category { + if (BUSINESS.has(name)) return "business"; if (FEATURED.has(name)) return "featured"; const lower = (name + " " + urlMain) .toLowerCase() @@ -156,50 +168,21 @@ export const PLATFORMS: Platform[] = Object.entries(rawData) return a.name.localeCompare(b.name); }); +const countOf = (cat: Category | "all") => + cat === "all" ? PLATFORMS.length : PLATFORMS.filter((p) => p.category === cat).length; + export const CATEGORIES: { id: Category | "all"; label: string; - description: string; }[] = [ - { id: "all", label: "All", description: `${PLATFORMS.length} platforms` }, - { - id: "featured", - label: "Featured", - description: `${PLATFORMS.filter((p) => p.category === "featured").length} platforms`, - }, - { - id: "social", - label: "Social", - description: `${PLATFORMS.filter((p) => p.category === "social").length} platforms`, - }, - { - id: "developer", - label: "Developer", - description: `${PLATFORMS.filter((p) => p.category === "developer").length} platforms`, - }, - { - id: "gaming", - label: "Gaming", - description: `${PLATFORMS.filter((p) => p.category === "gaming").length} platforms`, - }, - { - id: "creative", - label: "Creative", - description: `${PLATFORMS.filter((p) => p.category === "creative").length} platforms`, - }, - { - id: "music", - label: "Music", - description: `${PLATFORMS.filter((p) => p.category === "music").length} platforms`, - }, - { - id: "writing", - label: "Writing", - description: `${PLATFORMS.filter((p) => p.category === "writing").length} platforms`, - }, - { - id: "other", - label: "Other", - description: `${PLATFORMS.filter((p) => p.category === "other").length} platforms`, - }, + { id: "all", label: `All (${countOf("all")})` }, + { id: "featured", label: `Featured (${countOf("featured")})` }, + { id: "business", label: `Business (${countOf("business")})` }, + { id: "social", label: `Social (${countOf("social")})` }, + { id: "developer", label: `Developer (${countOf("developer")})` }, + { id: "gaming", label: `Gaming (${countOf("gaming")})` }, + { id: "creative", label: `Creative (${countOf("creative")})` }, + { id: "music", label: `Music (${countOf("music")})` }, + { id: "writing", label: `Writing (${countOf("writing")})` }, + { id: "other", label: `Other (${countOf("other")})` }, ]; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..41fc2af --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,37 @@ +/** In-memory rate limiter — unauthenticated users get ANON_DAILY_LIMIT lookups per day per IP. */ + +const ANON_DAILY_LIMIT = 5; + +type Entry = { count: number; day: string }; +const store = new Map(); + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +export function getRateLimitInfo(ip: string): { + allowed: boolean; + remaining: number; + limit: number; +} { + const day = today(); + const entry = store.get(ip); + + if (!entry || entry.day !== day) { + return { allowed: true, remaining: ANON_DAILY_LIMIT, limit: ANON_DAILY_LIMIT }; + } + + const remaining = Math.max(0, ANON_DAILY_LIMIT - entry.count); + return { allowed: remaining > 0, remaining, limit: ANON_DAILY_LIMIT }; +} + +export function incrementUsage(ip: string): void { + const day = today(); + const entry = store.get(ip); + + if (!entry || entry.day !== day) { + store.set(ip, { count: 1, day }); + } else { + store.set(ip, { count: entry.count + 1, day }); + } +} From b5d4871177857250394b9e92f02d5533d98d224e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 09:24:49 +0000 Subject: [PATCH 05/17] Fix double divider, double chevron, business category, duplicate pill counts; add motion animations - Hero: remove duplicate FullWidthDivider (kept single top divider with corner DecorIcons) - Lookup results: remove manual ChevronDown from SelectTrigger (shadcn renders its own) - Platforms: add Twitter/Instagram/YouTube/TikTok/Pinterest to Business category - Category pills: show count only in shadcn Badge, not duplicated in label text - Add motion/AnimatePresence for smooth crossfade between searches and staggered card entrances https://claude.ai/code/session_011nbTf3SpFDiLh1LkHfrxow --- package.json | 1 + pnpm-lock.yaml | 3 + src/components/hero.tsx | 4 +- src/components/lookup-results.tsx | 608 +++++++++++++----------------- src/lib/platforms.ts | 44 ++- 5 files changed, 285 insertions(+), 375 deletions(-) diff --git a/package.json b/package.json index 7b655ee..ce1b0df 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "initials": "^3.1.2", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "next": "16.1.7", "next-themes": "^0.4.6", "nuqs": "^2.8.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b563ee..8434b8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) + motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.1.7 version: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/src/components/hero.tsx b/src/components/hero.tsx index 75cebac..ec01712 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -45,13 +45,11 @@ export function HeroSection() {
{/* Bottom border with corner deco marks */} + {/* Bottom decorative divider — single line with corner marks */}
- - -
); diff --git a/src/components/lookup-results.tsx b/src/components/lookup-results.tsx index 852eb94..a5b57f5 100644 --- a/src/components/lookup-results.tsx +++ b/src/components/lookup-results.tsx @@ -8,8 +8,8 @@ import { XCircle, HelpCircle, Search, - ChevronDown, } from "lucide-react"; +import { motion, AnimatePresence } from "motion/react"; import { useQueryState, parseAsInteger } from "nuqs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -44,7 +44,7 @@ import { cn } from "@/lib/utils"; import { CATEGORIES, PLATFORMS, type Category } from "@/lib/platforms"; import type { PlatformResult } from "@/lib/lookup"; -// ── Constants ────────────────────────────────────────────────────────────── +// ── Constants ──────────────────────────────────────────────────────────── const PAGE_SIZE = 12; @@ -53,23 +53,20 @@ const STATUS_CFG = { label: "Available", icon: CheckCircle2, badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", - dot: "bg-emerald-500", }, taken: { label: "Taken", icon: XCircle, badge: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400", - dot: "bg-red-500", }, unknown: { label: "Unknown", icon: HelpCircle, badge: "text-muted-foreground", - dot: "bg-muted-foreground/40", }, } as const; -// ── Types ────────────────────────────────────────────────────────────────── +// ── Types ──────────────────────────────────────────────────────────────── interface LimitInfo { authenticated: boolean; @@ -78,7 +75,7 @@ interface LimitInfo { limit?: number; } -// ── Pagination helper ────────────────────────────────────────────────────── +// ── Pagination helper ──────────────────────────────────────────────────── function getPageNumbers(current: number, total: number): (number | "ellipsis")[] { if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); @@ -92,7 +89,7 @@ function getPageNumbers(current: number, total: number): (number | "ellipsis")[] return pages; } -// ── Sub-components ───────────────────────────────────────────────────────── +// ── Sub-components ─────────────────────────────────────────────────────── function LimitBadge({ info }: { info: LimitInfo | null }) { if (!info || info.unlimited) return null; @@ -107,16 +104,9 @@ function LimitBadge({ info }: { info: LimitInfo | null }) { : "border-border bg-muted/50 text-muted-foreground" )} > - + {isEmpty ? ( - <> - Limit reached ·{" "} - - Sign in - - + <>Limit reached · Sign in ) : ( `${remaining}/${limit} lookups today` )} @@ -124,18 +114,9 @@ function LimitBadge({ info }: { info: LimitInfo | null }) { ); } -function StatsBar({ - results, - total, - done, -}: { - results: PlatformResult[]; - total: number; - done: boolean; -}) { +function StatsBar({ results, total, done }: { results: PlatformResult[]; total: number; done: boolean }) { const available = results.filter((r) => r.status === "available").length; const taken = results.filter((r) => r.status === "taken").length; - return (
{!done ? ( @@ -146,14 +127,8 @@ function StatsBar({ ) : ( {results.length} checked )} - {available > 0 && ( - - {available} available - - )} - {taken > 0 && ( - {taken} taken - )} + {available > 0 && {available} available} + {taken > 0 && {taken} taken}
); } @@ -161,11 +136,11 @@ function StatsBar({ function ResultCard({ result, index }: { result: PlatformResult; index: number }) { const cfg = STATUS_CFG[result.status]; const Icon = cfg.icon; - return ( -
@@ -194,15 +169,15 @@ function ResultCard({ result, index }: { result: PlatformResult; index: number } -
+ ); } function SkeletonCard({ index }: { index: number }) { return (
@@ -220,154 +195,91 @@ function SkeletonCard({ index }: { index: number }) { ); } -// ── Main component ───────────────────────────────────────────────────────── +// ── Main component ─────────────────────────────────────────────────────── export function LookupResults() { - // URL state (all filters in URL for shareability) const [q] = useQueryState("q", { defaultValue: "" }); - const [category, setCategory] = useQueryState("category", { - defaultValue: "featured", - clearOnDefault: false, - }); - const [status, setStatus] = useQueryState("status", { - defaultValue: "all", - clearOnDefault: false, - }); - const [search, setSearch] = useQueryState("search", { - defaultValue: "", - clearOnDefault: true, - throttleMs: 250, - }); + const [category, setCategory] = useQueryState("category", { defaultValue: "featured", clearOnDefault: false }); + const [status, setStatus] = useQueryState("status", { defaultValue: "all", clearOnDefault: false }); + const [search, setSearch] = useQueryState("search", { defaultValue: "", clearOnDefault: true, throttleMs: 250 }); const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); - // Local state const [results, setResults] = useState([]); const [done, setDone] = useState(false); const [rateLimited, setRateLimited] = useState(false); const [limitInfo, setLimitInfo] = useState(null); - const [lookupKey, setLookupKey] = useState(0); const abortRef = useRef(null); - // Fetch rate limit status const fetchLimitInfo = useCallback(() => { - fetch("/api/lookup/status") - .then((r) => r.json()) - .then(setLimitInfo) - .catch(() => {}); + fetch("/api/lookup/status").then((r) => r.json()).then(setLimitInfo).catch(() => {}); }, []); - useEffect(() => { - fetchLimitInfo(); - }, [fetchLimitInfo]); - - // Start lookup using fetch+streams (gives us HTTP status access) - const startLookup = useCallback( - async (handle: string) => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - - setResults([]); - setDone(false); - setRateLimited(false); - - try { - const response = await fetch( - `/api/lookup?handle=${encodeURIComponent(handle)}`, - { signal: controller.signal } - ); - - if (response.status === 429) { - setRateLimited(true); - setDone(true); - fetchLimitInfo(); - return; - } - - if (!response.ok || !response.body) { - setDone(true); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done: streamDone, value } = await reader.read(); - if (streamDone) break; - - buffer += decoder.decode(value, { stream: true }); - const chunks = buffer.split("\n\n"); - buffer = chunks.pop() ?? ""; - - for (const chunk of chunks) { - if (!chunk.startsWith("data: ")) continue; - try { - const data = JSON.parse(chunk.slice(6)) as PlatformResult & { done?: boolean }; - if (data.done) { - setDone(true); - fetchLimitInfo(); - return; - } - setResults((prev) => [...prev, data]); - } catch { - // ignore parse errors - } - } - } - - setDone(true); - } catch (err) { - if (!(err instanceof DOMException && err.name === "AbortError")) { - setDone(true); + useEffect(() => { fetchLimitInfo(); }, [fetchLimitInfo]); + + const startLookup = useCallback(async (handle: string) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setResults([]); + setDone(false); + setRateLimited(false); + + try { + const response = await fetch(`/api/lookup?handle=${encodeURIComponent(handle)}`, { signal: controller.signal }); + + if (response.status === 429) { setRateLimited(true); setDone(true); fetchLimitInfo(); return; } + if (!response.ok || !response.body) { setDone(true); return; } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done: streamDone, value } = await reader.read(); + if (streamDone) break; + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + for (const chunk of chunks) { + if (!chunk.startsWith("data: ")) continue; + try { + const data = JSON.parse(chunk.slice(6)) as PlatformResult & { done?: boolean }; + if (data.done) { setDone(true); fetchLimitInfo(); return; } + setResults((prev) => [...prev, data]); + } catch { /* ignore */ } } } - }, - [fetchLimitInfo] - ); + setDone(true); + } catch (err) { + if (!(err instanceof DOMException && err.name === "AbortError")) setDone(true); + } + }, [fetchLimitInfo]); - // Trigger lookup when q changes useEffect(() => { - if (!q) { - abortRef.current?.abort(); - setResults([]); - setDone(false); - setRateLimited(false); - return; - } - setLookupKey((k) => k + 1); + if (!q) { abortRef.current?.abort(); setResults([]); setDone(false); setRateLimited(false); return; } setPage(1); startLookup(q); return () => abortRef.current?.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [q]); - // Reset page when filters change const handleCategory = (v: string) => { setCategory(v); setPage(1); }; const handleStatus = (v: string) => { setStatus(v); setPage(1); }; const handleSearch = (v: string) => { setSearch(v); setPage(1); }; - // Combined filtering + sorting const filtered = useMemo(() => { let list = [...results]; - const cat = category ?? "featured"; if (cat !== "all") list = list.filter((r) => r.category === cat); - const st = status ?? "all"; if (st !== "all") list = list.filter((r) => r.status === st); - const s = (search ?? "").toLowerCase().trim(); if (s) list = list.filter((r) => r.platform.toLowerCase().includes(s)); - - // Sort: available → taken → unknown, then alphabetical list.sort((a, b) => { const order = { available: 0, taken: 1, unknown: 2 }; const diff = order[a.status] - order[b.status]; return diff !== 0 ? diff : a.platform.localeCompare(b.platform); }); - return list; }, [results, category, status, search]); @@ -375,222 +287,220 @@ export function LookupResults() { const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const currentPage = Math.min(Math.max(1, page ?? 1), totalPages); const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + const skeletonCount = !done && currentPage === 1 ? Math.max(0, PAGE_SIZE - paginated.length) : 0; - // Skeleton fill: show up to PAGE_SIZE skeletons while loading on page 1 - const skeletonCount = - !done && currentPage === 1 ? Math.max(0, PAGE_SIZE - paginated.length) : 0; - - // Per-category result counts for pill badges const catCounts = useMemo( - () => - results.reduce>((acc, r) => { - acc.all = (acc.all ?? 0) + 1; - acc[r.category] = (acc[r.category] ?? 0) + 1; - return acc; - }, {}), + () => results.reduce>((acc, r) => { + acc.all = (acc.all ?? 0) + 1; + acc[r.category] = (acc[r.category] ?? 0) + 1; + return acc; + }, {}), [results] ); - // ── Nothing to show ────────────────────────────────────────────────────── - if (!q) return null; - - // ── Rate limited ───────────────────────────────────────────────────────── - if (rateLimited) { - return ( -
-

- Daily limit reached.{" "} - - Sign in - {" "} - for unlimited lookups. -

-
- ); - } - const activeCat = (category ?? "featured") as Category | "all"; const activeStatus = status ?? "all"; return ( -
- - {/* Handle + stats */} -
-

- @{q} -

- -
- - {/* Progress bar */} -
- -
- - {/* ── Filter toolbar ── */} -
- {/* Row 1: search + status + limit */} -
- {/* Platform search */} -
- - handleSearch(e.target.value)} - /> + + {q && !rateLimited && ( + + {/* Handle + stats */} + +

@{q}

+ +
+ + {/* Progress */} +
+
- {/* Availability filter */} - - - {/* Lookup limit badge */} - -
- - {/* Row 2: category pills */} -
- {CATEGORIES.map((cat) => { - const count = catCounts[cat.id] ?? 0; - const isActive = activeCat === cat.id; - return ( - - ); - })} -
-
- - {/* ── Results grid (keyed for clean transition on new search) ── */} -
-
- {paginated.map((result, i) => ( - - ))} - {Array.from({ length: skeletonCount }, (_, i) => ( - - ))} -
- - {/* Empty state (done + no results for current filter) */} - {done && filtered.length === 0 && ( -
-

- No platforms match your filters. -

- + {cat.label} + {count > 0 && ( + + {count} + + )} + + ); + })} +
+ + + {/* Results grid */} +
+ {paginated.map((result, i) => ( + + ))} + {Array.from({ length: skeletonCount }, (_, i) => ( + + ))}
- )} -
- - {/* ── Pagination ── */} - {totalPages > 1 && ( -
- - - - setPage(Math.max(1, currentPage - 1))} - className={cn( - "cursor-pointer select-none transition-opacity", - currentPage <= 1 && "pointer-events-none opacity-40" - )} - /> - - - {getPageNumbers(currentPage, totalPages).map((p, i) => - p === "ellipsis" ? ( - - - - ) : ( - - setPage(p)} - className="cursor-pointer select-none transition-colors" - > - {p} - - - ) - )} - - - setPage(Math.min(totalPages, currentPage + 1))} - className={cn( - "cursor-pointer select-none transition-opacity", - currentPage >= totalPages && "pointer-events-none opacity-40" - )} - /> - - - -
+ + {/* Empty state */} + + {done && filtered.length === 0 && ( + +

No platforms match your filters.

+ +
+ )} +
+ + {/* Pagination */} + + {totalPages > 1 && ( + + + + + setPage(Math.max(1, currentPage - 1))} + className={cn("cursor-pointer select-none transition-opacity", currentPage <= 1 && "pointer-events-none opacity-40")} + /> + + {getPageNumbers(currentPage, totalPages).map((p, i) => + p === "ellipsis" ? ( + + ) : ( + + setPage(p)} + className="cursor-pointer select-none transition-colors" + > + {p} + + + ) + )} + + setPage(Math.min(totalPages, currentPage + 1))} + className={cn("cursor-pointer select-none transition-opacity", currentPage >= totalPages && "pointer-events-none opacity-40")} + /> + + + + + )} + + )} -
+ + {q && rateLimited && ( + +

+ Daily limit reached.{" "} + Sign in{" "} + for unlimited lookups. +

+
+ )} + ); } diff --git a/src/lib/platforms.ts b/src/lib/platforms.ts index 75f5f0f..6f4630c 100644 --- a/src/lib/platforms.ts +++ b/src/lib/platforms.ts @@ -30,14 +30,23 @@ export interface Platform { // ── Category Rules ────────────────────────────────────────────────────────── -// Business-first: platforms every new business founder should claim +// Business: platforms every founder should claim — social channels + professional tools const BUSINESS = new Set([ + // Social networks every business needs + "Twitter", + "Instagram", + "YouTube", + "TikTok", "LinkedIn", + "Pinterest", + // Discovery & launch "ProductHunt", + // Creator monetization "Patreon", "Gumroad", "kofi", "BuyMeACoffee", + // Freelance & tools "Freelancer", "Trello", "Wix", @@ -45,13 +54,8 @@ const BUSINESS = new Set([ const FEATURED = new Set([ "GitHub", - "Instagram", - "Twitter", - "TikTok", - "YouTube", "Reddit", "Snapchat", - "Pinterest", "Twitch", "Discord", "Telegram", @@ -168,21 +172,15 @@ export const PLATFORMS: Platform[] = Object.entries(rawData) return a.name.localeCompare(b.name); }); -const countOf = (cat: Category | "all") => - cat === "all" ? PLATFORMS.length : PLATFORMS.filter((p) => p.category === cat).length; - -export const CATEGORIES: { - id: Category | "all"; - label: string; -}[] = [ - { id: "all", label: `All (${countOf("all")})` }, - { id: "featured", label: `Featured (${countOf("featured")})` }, - { id: "business", label: `Business (${countOf("business")})` }, - { id: "social", label: `Social (${countOf("social")})` }, - { id: "developer", label: `Developer (${countOf("developer")})` }, - { id: "gaming", label: `Gaming (${countOf("gaming")})` }, - { id: "creative", label: `Creative (${countOf("creative")})` }, - { id: "music", label: `Music (${countOf("music")})` }, - { id: "writing", label: `Writing (${countOf("writing")})` }, - { id: "other", label: `Other (${countOf("other")})` }, +export const CATEGORIES: { id: Category | "all"; label: string }[] = [ + { id: "all", label: "All" }, + { id: "featured", label: "Featured" }, + { id: "business", label: "Business" }, + { id: "social", label: "Social" }, + { id: "developer", label: "Developer" }, + { id: "gaming", label: "Gaming" }, + { id: "creative", label: "Creative" }, + { id: "music", label: "Music" }, + { id: "writing", label: "Writing" }, + { id: "other", label: "Other" }, ]; From d42372ea690a4bb115fa81839bcacaf29abeef9f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 09:36:32 +0000 Subject: [PATCH 06/17] Responsive layout, subtle animations, idle/empty states, UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full responsive: form full-width on mobile, filter row stacks on sm, grid 1→2→3 cols - Category pills: negative margin bleed to screen edges on mobile with overflow scroll - Pagination: wraps on small screens - Animations: reduced to y:6/y:8, duration 0.18-0.2, cubic-bezier ease, less intrusive - Idle state: mono hint text shown before any lookup ("↑ enter a handle…") - Empty state: icon + heading + subtext + clear filters button (replaces plain text) - Rate-limit state: same icon + heading pattern as empty state - LookupForm: @ prefix in input, full-width with flex-1 input, cleaner submit button - ResultCard: ghost button for view link, mono URL text, softer hover shadow - Hero: removed duplicate comment, adjusted mobile padding, removed
in subtitle - Progress bar: h-px (thinner, less obtrusive) https://claude.ai/code/session_011nbTf3SpFDiLh1LkHfrxow --- src/components/hero.tsx | 12 +- src/components/lookup-form.tsx | 31 +-- src/components/lookup-results.tsx | 303 +++++++++++++++++++----------- 3 files changed, 218 insertions(+), 128 deletions(-) diff --git a/src/components/hero.tsx b/src/components/hero.tsx index ec01712..6db0f9e 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -6,7 +6,7 @@ import { LookupForm } from "@/components/lookup-form"; export function HeroSection() { return (
-
+
{/* Faded borders & radial gradient */}