refactor: split Finder into NameCard/Wishlist/AiControls + useOtmSession/useWishlist hooks + availability-client (v0.4.1)
Pure code motion; Finder.tsx 754 → 347 lines. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
This commit is contained in:
@@ -88,6 +88,14 @@ bare unstyled button (issue #2). Tailwind is configured with `preflight: false`
|
||||
scoped to `.wrap`, and the widget mounts *outside* `.wrap`, so neither side can
|
||||
restyle the other. Keep it that way when adding global CSS.
|
||||
|
||||
## Client layout
|
||||
|
||||
`components/Finder.tsx` is orchestration only (inputs → generate → lookup).
|
||||
Cards are `NameCard.tsx`, the Saved panel `Wishlist.tsx`, the AI switch
|
||||
`AiControls.tsx`; session and shortlist state are `hooks/useOtmSession.ts` and
|
||||
`hooks/useWishlist.ts`; the availability result shape + batch fetch is
|
||||
`lib/availability-client.ts`. Add UI to the piece it belongs to, not to Finder.
|
||||
|
||||
## Wishlist is browser-only
|
||||
|
||||
`src/lib/wishlist.ts` — `localStorage` key `names.wishlist`, capped at 60. It
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "names-poweredbyotm",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
|
||||
67
src/components/AiControls.tsx
Normal file
67
src/components/AiControls.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
// The AI switch next to the generate button. Three states, one slot:
|
||||
// own key saved → "Use my Claude/Gemini key" (browser-direct path)
|
||||
// signed in → "AI names" (platform gateway) + sign out
|
||||
// platform wired → "Sign in with OTM for AI names →"
|
||||
// none → nothing
|
||||
// A saved key always wins over the platform path — see CLAUDE.md "AI — two paths".
|
||||
|
||||
import type { AiSettings } from "@/lib/ai";
|
||||
import { otmSsoUrl } from "@/lib/otm";
|
||||
import type { OtmAccount } from "@/hooks/useOtmSession";
|
||||
|
||||
const LABEL_STYLE = { display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" } as const;
|
||||
|
||||
export function AiControls({
|
||||
ai,
|
||||
useAi,
|
||||
onUseAi,
|
||||
platformAi,
|
||||
account,
|
||||
usePlatformAi,
|
||||
onUsePlatformAi,
|
||||
onSignOut,
|
||||
}: {
|
||||
ai: AiSettings | null;
|
||||
useAi: boolean;
|
||||
onUseAi: (v: boolean) => void;
|
||||
platformAi: boolean;
|
||||
account: OtmAccount | null;
|
||||
usePlatformAi: boolean;
|
||||
onUsePlatformAi: (v: boolean) => void;
|
||||
onSignOut: () => void;
|
||||
}) {
|
||||
if (ai) {
|
||||
return (
|
||||
<label style={LABEL_STYLE}>
|
||||
<input type="checkbox" checked={useAi} onChange={(e) => onUseAi(e.target.checked)} style={{ width: "auto" }} />
|
||||
Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (platformAi && account) {
|
||||
return (
|
||||
<label style={LABEL_STYLE} title={`Signed in as ${account.email}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePlatformAi}
|
||||
onChange={(e) => onUsePlatformAi(e.target.checked)}
|
||||
style={{ width: "auto" }}
|
||||
/>
|
||||
AI names
|
||||
<button type="button" className="linkish" onClick={onSignOut} title={`Sign out ${account.email}`}>
|
||||
sign out
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (platformAi) {
|
||||
return (
|
||||
<a className="signin" href={otmSsoUrl("/")} title="Free OTM account · adds AI-generated names">
|
||||
Sign in with OTM for AI names →
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,32 +1,27 @@
|
||||
"use client";
|
||||
|
||||
// The finder: keywords/style/extensions in, generated names + live
|
||||
// availability out. Orchestration only — cards, the saved panel, the AI
|
||||
// switch, session and wishlist state each live in their own module.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateNames, scoreName, type Idea, type Style } from "@/lib/generate";
|
||||
import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
|
||||
import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
|
||||
import { DEFAULT_REGISTRAR, REGISTRARS } from "@/lib/registrars";
|
||||
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
|
||||
import { generateWithPlatformAi } from "@/lib/platform-ai";
|
||||
import { otmSsoUrl } from "@/lib/otm";
|
||||
import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
|
||||
|
||||
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||
|
||||
interface DomainCheck {
|
||||
domain: string;
|
||||
status: Availability;
|
||||
method: "rdap" | "dns" | "none";
|
||||
detail?: string;
|
||||
}
|
||||
import { BATCH, domainsFor, fetchAvailability, type DomainCheck } from "@/lib/availability-client";
|
||||
import { useOtmSession } from "@/hooks/useOtmSession";
|
||||
import { useWishlist } from "@/hooks/useWishlist";
|
||||
import { NameCard } from "./NameCard";
|
||||
import { WishlistPanel } from "./Wishlist";
|
||||
import { AiControls } from "./AiControls";
|
||||
|
||||
/** How many names to put on screen. Each one costs `selectedTlds.length`
|
||||
* registry lookups, so this is the main dial on how hard we lean on the
|
||||
* registries — keep it modest. */
|
||||
const NAME_COUNT = 24;
|
||||
|
||||
/** Domains per availability request. The route caps at 60; staying under it
|
||||
* means results paint in progressively instead of in one late lump. */
|
||||
const BATCH = 40;
|
||||
|
||||
const STYLES: Array<{ id: Style; label: string; note: string }> = [
|
||||
{ id: "balanced", label: "Balanced", note: "a bit of everything" },
|
||||
{ id: "real", label: "Real words", note: "northbakery, axlekit" },
|
||||
@@ -47,125 +42,16 @@ export default function Finder() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ai, setAi] = useState<AiSettings | null>(null);
|
||||
const [useAi, setUseAi] = useState(false);
|
||||
// Platform AI: who's signed in with OTM (null = nobody) and whether this
|
||||
// deployment has the gateway wired at all. Fetched once on mount.
|
||||
const [account, setAccount] = useState<{ email: string; name: string | null } | null>(null);
|
||||
const [platformAi, setPlatformAi] = useState(false);
|
||||
const [usePlatformAi, setUsePlatformAi] = useState(true);
|
||||
const [signinNote, setSigninNote] = useState<string | null>(null);
|
||||
const { account, platformAi, signinNote, signOut } = useOtmSession();
|
||||
// How many names the last run got from AI vs the built-in generator — shown
|
||||
// as a one-line summary so it's obvious whether AI actually contributed
|
||||
// (issue #3). null until a run completes.
|
||||
const [aiCount, setAiCount] = useState<number | null>(null);
|
||||
// Hide names whose every checked extension is taken (issue #3). Persisted.
|
||||
const [hideTaken, setHideTaken] = useState(false);
|
||||
const [saved, setSaved] = useState<SavedName[]>([]);
|
||||
const [savedLoaded, setSavedLoaded] = useState(false);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Wishlist: read once on mount, written on every change after that. The
|
||||
// `savedLoaded` gate stops the first (empty) render from clobbering storage.
|
||||
useEffect(() => {
|
||||
setSaved(loadWishlist());
|
||||
setSavedLoaded(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (savedLoaded) saveWishlist(saved);
|
||||
}, [saved, savedLoaded]);
|
||||
|
||||
// Keep saved snapshots current: whenever a lookup resolves a domain that's on
|
||||
// the shortlist, copy the fresh status in. Cheap, and it means a name saved
|
||||
// while still "checking" ends up with a real answer.
|
||||
useEffect(() => {
|
||||
setSaved((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((e) => {
|
||||
let entry = e;
|
||||
for (const d of e.domains) {
|
||||
const c = checks[d];
|
||||
if (!c) continue;
|
||||
const old = entry.checks[d];
|
||||
if (old && old.status === c.status && old.detail === c.detail) continue;
|
||||
entry = { ...entry, checks: { ...entry.checks, [d]: { status: c.status, method: c.method, detail: c.detail } } };
|
||||
changed = true;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [checks]);
|
||||
|
||||
const savedNames = useMemo(() => new Set(saved.map((e) => e.name)), [saved]);
|
||||
|
||||
const toggleSaved = useCallback(
|
||||
(name: string, domains: string[]) => {
|
||||
setSaved((prev) => {
|
||||
if (prev.some((e) => e.name === name)) return prev.filter((e) => e.name !== name);
|
||||
if (prev.length >= WISHLIST_MAX) return prev;
|
||||
const snapshot: SavedName["checks"] = {};
|
||||
for (const d of domains) {
|
||||
const c = checks[d];
|
||||
if (c) snapshot[d] = { status: c.status, method: c.method, detail: c.detail };
|
||||
}
|
||||
return [{ name, domains, checks: snapshot, savedAt: new Date().toISOString() }, ...prev];
|
||||
});
|
||||
},
|
||||
[checks],
|
||||
);
|
||||
|
||||
const removeSaved = (name: string) => setSaved((prev) => prev.filter((e) => e.name !== name));
|
||||
|
||||
/** Re-query every saved domain. Independent of the main run's cancellation —
|
||||
* a shortlist re-check shouldn't die because the visitor hit "Show me more". */
|
||||
const recheckSaved = async () => {
|
||||
const domains = Array.from(new Set(saved.flatMap((e) => e.domains)));
|
||||
if (domains.length === 0) return;
|
||||
setRechecking(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (let i = 0; i < domains.length; i += BATCH) {
|
||||
const slice = domains.slice(i, i + BATCH);
|
||||
const res = await fetch("/api/availability", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ domains: slice }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(body.error ?? `Availability lookup failed (${res.status}).`);
|
||||
break;
|
||||
}
|
||||
const body = (await res.json()) as { results: DomainCheck[] };
|
||||
const fresh: Record<string, DomainCheck> = {};
|
||||
for (const r of body.results) fresh[r.domain] = r;
|
||||
setSaved((prev) =>
|
||||
prev.map((e) => {
|
||||
const nextChecks = { ...e.checks };
|
||||
for (const d of e.domains) {
|
||||
const c = fresh[d];
|
||||
if (c) nextChecks[d] = { status: c.status, method: c.method, detail: c.detail };
|
||||
}
|
||||
return { ...e, checks: nextChecks };
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setError("Could not reach the availability service.");
|
||||
} finally {
|
||||
setRechecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copySaved = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wishlistAsText(saved));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setError("Couldn't copy — your browser blocked clipboard access.");
|
||||
}
|
||||
};
|
||||
const { saved, savedNames, toggleSaved, removeSaved, recheckSaved, copySaved, rechecking, copied } =
|
||||
useWishlist(checks, setError);
|
||||
|
||||
// The saved key is read once on mount; Settings lives on its own page, so it
|
||||
// can't change underneath us mid-session.
|
||||
@@ -187,38 +73,6 @@ export default function Finder() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch("/api/auth/session", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((b: { platformAi?: boolean; user?: { email: string; name: string | null } | null } | null) => {
|
||||
if (!alive || !b) return;
|
||||
setPlatformAi(Boolean(b.platformAi));
|
||||
setAccount(b.user ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
// The SSO callback bounces failures back here as ?signin=<reason>.
|
||||
const reason = new URLSearchParams(window.location.search).get("signin");
|
||||
if (reason) {
|
||||
setSigninNote(
|
||||
reason === "link_already_used"
|
||||
? "That sign-in link was already used — try again."
|
||||
: reason === "not_enabled"
|
||||
? "Sign-in isn't enabled on this deployment."
|
||||
: "Sign-in didn't complete — try again.",
|
||||
);
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const signOut = async () => {
|
||||
await fetch("/api/auth/signout", { method: "POST" }).catch(() => {});
|
||||
setAccount(null);
|
||||
};
|
||||
|
||||
// Abandon in-flight lookups when a new search starts, so a slow batch from
|
||||
// the previous query can't paint stale statuses over the new results.
|
||||
const runId = useRef(0);
|
||||
@@ -232,22 +86,6 @@ export default function Finder() {
|
||||
[keywords],
|
||||
);
|
||||
|
||||
/** Every domain a set of ideas implies, in display order. */
|
||||
const domainsFor = useCallback(
|
||||
(list: Idea[], selected: string[]): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const idea of list) {
|
||||
if (idea.hackTld && idea.hackStem) {
|
||||
out.push(`${idea.hackStem}.${idea.hackTld}`);
|
||||
continue;
|
||||
}
|
||||
for (const tld of selected) out.push(`${idea.name}.${tld}`);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const lookup = useCallback(async (domains: string[], myRun: number) => {
|
||||
setPending(new Set(domains));
|
||||
|
||||
@@ -255,28 +93,16 @@ export default function Finder() {
|
||||
if (runId.current !== myRun) return; // superseded by a newer search
|
||||
const slice = domains.slice(i, i + BATCH);
|
||||
try {
|
||||
const res = await fetch("/api/availability", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ domains: slice }),
|
||||
});
|
||||
const results = await fetchAvailability(slice);
|
||||
if (runId.current !== myRun) return;
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(body.error ?? `Availability lookup failed (${res.status}).`);
|
||||
setPending(new Set());
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as { results: DomainCheck[] };
|
||||
setChecks((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of body.results) next[r.domain] = r;
|
||||
for (const r of results) next[r.domain] = r;
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (runId.current !== myRun) return;
|
||||
setError("Could not reach the availability service.");
|
||||
setError(err instanceof Error ? err.message : "Could not reach the availability service.");
|
||||
setPending(new Set());
|
||||
return;
|
||||
}
|
||||
@@ -333,7 +159,7 @@ export default function Finder() {
|
||||
setBusy(false);
|
||||
await lookup(domainsFor(list, tlds), myRun);
|
||||
},
|
||||
[account, ai, domainsFor, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
|
||||
[account, ai, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
|
||||
);
|
||||
|
||||
const onGenerate = () => {
|
||||
@@ -350,13 +176,10 @@ export default function Finder() {
|
||||
|
||||
/** A name is "all taken" once every domain we checked for it resolved taken.
|
||||
* Still-checking or unknown names stay visible — hiding is for certainty. */
|
||||
const isAllTaken = useCallback(
|
||||
(idea: Idea): boolean => {
|
||||
const ds = domainsFor([idea], tlds);
|
||||
return ds.length > 0 && ds.every((d) => checks[d]?.status === "taken");
|
||||
},
|
||||
[checks, domainsFor, tlds],
|
||||
);
|
||||
const isAllTaken = (idea: Idea): boolean => {
|
||||
const ds = domainsFor([idea], tlds);
|
||||
return ds.length > 0 && ds.every((d) => checks[d]?.status === "taken");
|
||||
};
|
||||
const visibleIdeas = hideTaken ? ideas.filter((i) => !isAllTaken(i)) : ideas;
|
||||
const hiddenCount = ideas.length - visibleIdeas.length;
|
||||
|
||||
@@ -438,39 +261,16 @@ export default function Finder() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{ai ? (
|
||||
<label
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useAi}
|
||||
onChange={(e) => setUseAi(e.target.checked)}
|
||||
style={{ width: "auto" }}
|
||||
/>
|
||||
Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key
|
||||
</label>
|
||||
) : platformAi && account ? (
|
||||
<label
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
|
||||
title={`Signed in as ${account.email}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePlatformAi}
|
||||
onChange={(e) => setUsePlatformAi(e.target.checked)}
|
||||
style={{ width: "auto" }}
|
||||
/>
|
||||
AI names
|
||||
<button type="button" className="linkish" onClick={signOut} title={`Sign out ${account.email}`}>
|
||||
sign out
|
||||
</button>
|
||||
</label>
|
||||
) : platformAi ? (
|
||||
<a className="signin" href={otmSsoUrl("/")} title="Free OTM account · adds AI-generated names">
|
||||
Sign in with OTM for AI names →
|
||||
</a>
|
||||
) : null}
|
||||
<AiControls
|
||||
ai={ai}
|
||||
useAi={useAi}
|
||||
onUseAi={setUseAi}
|
||||
platformAi={platformAi}
|
||||
account={account}
|
||||
usePlatformAi={usePlatformAi}
|
||||
onUsePlatformAi={setUsePlatformAi}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tlds.length === 0 ? (
|
||||
@@ -492,32 +292,15 @@ export default function Finder() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{saved.length > 0 ? (
|
||||
<section className="wishlist" aria-label="Saved names">
|
||||
<div className="wishlist-head">
|
||||
<h2>
|
||||
Saved <span className="count">{saved.length}</span>
|
||||
</h2>
|
||||
<div className="wishlist-actions">
|
||||
<button type="button" className="mini" onClick={recheckSaved} disabled={rechecking}>
|
||||
{rechecking ? "Checking…" : "Re-check"}
|
||||
</button>
|
||||
<button type="button" className="mini" onClick={copySaved}>
|
||||
{copied ? "Copied" : "Copy list"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint">
|
||||
Kept in this browser only. Statuses are from the last time each name was checked
|
||||
— press Re-check before you commit.
|
||||
</p>
|
||||
<div className="results">
|
||||
{saved.map((entry) => (
|
||||
<SavedCard key={entry.name} entry={entry} registrar={registrar} onRemove={() => removeSaved(entry.name)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<WishlistPanel
|
||||
saved={saved}
|
||||
registrar={registrar}
|
||||
rechecking={rechecking}
|
||||
copied={copied}
|
||||
onRecheck={recheckSaved}
|
||||
onCopy={copySaved}
|
||||
onRemove={removeSaved}
|
||||
/>
|
||||
|
||||
{ideas.length === 0 ? (
|
||||
<p className="empty">
|
||||
@@ -562,193 +345,3 @@ export default function Finder() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NameCard({
|
||||
idea,
|
||||
tlds,
|
||||
checks,
|
||||
pending,
|
||||
registrar,
|
||||
saved,
|
||||
onToggleSave,
|
||||
hideTaken,
|
||||
}: {
|
||||
idea: Idea;
|
||||
tlds: string[];
|
||||
checks: Record<string, DomainCheck>;
|
||||
pending: Set<string>;
|
||||
registrar: string;
|
||||
saved: boolean;
|
||||
onToggleSave: (name: string, domains: string[]) => void;
|
||||
hideTaken: boolean;
|
||||
}) {
|
||||
const isHack = Boolean(idea.hackTld && idea.hackStem);
|
||||
const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
|
||||
const saveKey = isHack ? domains[0] : idea.name;
|
||||
|
||||
// Dim the whole card only when every extension we checked came back taken.
|
||||
const settled = domains.map((d) => checks[d]).filter(Boolean);
|
||||
const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
|
||||
|
||||
return (
|
||||
<div className={`card${allTaken ? " is-taken" : ""}${saved ? " is-saved" : ""}`}>
|
||||
<div className="card-head">
|
||||
<div className="card-name">
|
||||
{isHack ? (
|
||||
<>
|
||||
{idea.hackStem}
|
||||
<span className="tld">.{idea.hackTld}</span>
|
||||
</>
|
||||
) : (
|
||||
idea.name
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="save"
|
||||
aria-pressed={saved}
|
||||
title={saved ? "Remove from saved" : "Save for later"}
|
||||
aria-label={saved ? `Remove ${saveKey} from saved` : `Save ${saveKey}`}
|
||||
onClick={() => onToggleSave(saveKey, domains)}
|
||||
>
|
||||
{saved ? "★" : "☆"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{domains.map((domain) => {
|
||||
const check = checks[domain];
|
||||
const isPending = pending.has(domain) && !check;
|
||||
const status: Availability | "checking" = isPending ? "checking" : (check?.status ?? "unknown");
|
||||
const free = status === "available" || status === "unverified-available";
|
||||
if (hideTaken && status === "taken") return null;
|
||||
|
||||
return (
|
||||
<div className="card-meta" key={domain}>
|
||||
<span className={`status ${status}`} title={check?.detail}>
|
||||
{statusIcon(status)} {isHack ? domain : `.${domain.slice(idea.name.length + 1)}`}{" "}
|
||||
<span style={{ fontWeight: 400 }}>{statusLabel(status)}</span>
|
||||
</span>
|
||||
{free ? (
|
||||
<a
|
||||
className="register-link"
|
||||
href={registerUrl(domain, registrar)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Register →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<span className="strategy">
|
||||
{idea.strategy === "ai" ? <span className="ai-badge">AI</span> : null} {strategyLabel(idea.strategy)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedCard({
|
||||
entry,
|
||||
registrar,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: SavedName;
|
||||
registrar: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const isHack = entry.domains.length === 1 && entry.domains[0] === entry.name;
|
||||
const settled = entry.domains.map((d) => entry.checks[d]).filter(Boolean);
|
||||
const allTaken = settled.length === entry.domains.length && settled.every((c) => c.status === "taken");
|
||||
return (
|
||||
<div className={`card is-saved${allTaken ? " is-taken" : ""}`}>
|
||||
<div className="card-head">
|
||||
<div className="card-name">{entry.name}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="save"
|
||||
aria-pressed
|
||||
title="Remove from saved"
|
||||
aria-label={`Remove ${entry.name} from saved`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
</div>
|
||||
{entry.domains.map((domain) => {
|
||||
const check = entry.checks[domain];
|
||||
const status: Availability = check?.status ?? "unknown";
|
||||
const free = status === "available" || status === "unverified-available";
|
||||
return (
|
||||
<div className="card-meta" key={domain}>
|
||||
<span className={`status ${status}`} title={check?.detail}>
|
||||
{statusIcon(status)} {isHack ? domain : `.${domain.slice(entry.name.length + 1)}`}{" "}
|
||||
<span style={{ fontWeight: 400 }}>{statusLabel(status)}</span>
|
||||
</span>
|
||||
{free ? (
|
||||
<a className="register-link" href={registerUrl(domain, registrar)} target="_blank" rel="noopener noreferrer">
|
||||
Register →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusIcon(status: Availability | "checking"): string {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return "●";
|
||||
case "unverified-available":
|
||||
return "◐";
|
||||
case "taken":
|
||||
return "○";
|
||||
case "checking":
|
||||
return "…";
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: Availability | "checking"): string {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return "free";
|
||||
case "unverified-available":
|
||||
return "probably free";
|
||||
case "taken":
|
||||
return "taken";
|
||||
case "checking":
|
||||
return "checking";
|
||||
default:
|
||||
return "unclear";
|
||||
}
|
||||
}
|
||||
|
||||
function strategyLabel(strategy: Idea["strategy"]): string {
|
||||
switch (strategy) {
|
||||
case "compound":
|
||||
return "compound";
|
||||
case "suffix-word":
|
||||
return "compound";
|
||||
case "action":
|
||||
return "call to action";
|
||||
case "blend":
|
||||
return "blend";
|
||||
case "coined":
|
||||
return "coined";
|
||||
case "clipped":
|
||||
return "clipped";
|
||||
case "root":
|
||||
return "classical root";
|
||||
case "hack":
|
||||
return "domain hack";
|
||||
case "ai":
|
||||
return "generated";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
149
src/components/NameCard.tsx
Normal file
149
src/components/NameCard.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
// One name → its per-extension availability rows, register links, save star.
|
||||
// Pure display: everything it shows arrives in props.
|
||||
|
||||
import type { Idea } from "@/lib/generate";
|
||||
import { registerUrl } from "@/lib/registrars";
|
||||
import type { Availability, DomainCheck } from "@/lib/availability-client";
|
||||
|
||||
export function NameCard({
|
||||
idea,
|
||||
tlds,
|
||||
checks,
|
||||
pending,
|
||||
registrar,
|
||||
saved,
|
||||
onToggleSave,
|
||||
hideTaken,
|
||||
}: {
|
||||
idea: Idea;
|
||||
tlds: string[];
|
||||
checks: Record<string, DomainCheck>;
|
||||
pending: Set<string>;
|
||||
registrar: string;
|
||||
saved: boolean;
|
||||
onToggleSave: (name: string, domains: string[]) => void;
|
||||
hideTaken: boolean;
|
||||
}) {
|
||||
const isHack = Boolean(idea.hackTld && idea.hackStem);
|
||||
const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
|
||||
const saveKey = isHack ? domains[0] : idea.name;
|
||||
|
||||
// Dim the whole card only when every extension we checked came back taken.
|
||||
const settled = domains.map((d) => checks[d]).filter(Boolean);
|
||||
const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
|
||||
|
||||
return (
|
||||
<div className={`card${allTaken ? " is-taken" : ""}${saved ? " is-saved" : ""}`}>
|
||||
<div className="card-head">
|
||||
<div className="card-name">
|
||||
{isHack ? (
|
||||
<>
|
||||
{idea.hackStem}
|
||||
<span className="tld">.{idea.hackTld}</span>
|
||||
</>
|
||||
) : (
|
||||
idea.name
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="save"
|
||||
aria-pressed={saved}
|
||||
title={saved ? "Remove from saved" : "Save for later"}
|
||||
aria-label={saved ? `Remove ${saveKey} from saved` : `Save ${saveKey}`}
|
||||
onClick={() => onToggleSave(saveKey, domains)}
|
||||
>
|
||||
{saved ? "★" : "☆"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{domains.map((domain) => {
|
||||
const check = checks[domain];
|
||||
const isPending = pending.has(domain) && !check;
|
||||
const status: Availability | "checking" = isPending ? "checking" : (check?.status ?? "unknown");
|
||||
const free = status === "available" || status === "unverified-available";
|
||||
if (hideTaken && status === "taken") return null;
|
||||
|
||||
return (
|
||||
<div className="card-meta" key={domain}>
|
||||
<span className={`status ${status}`} title={check?.detail}>
|
||||
{statusIcon(status)} {isHack ? domain : `.${domain.slice(idea.name.length + 1)}`}{" "}
|
||||
<span style={{ fontWeight: 400 }}>{statusLabel(status)}</span>
|
||||
</span>
|
||||
{free ? (
|
||||
<a
|
||||
className="register-link"
|
||||
href={registerUrl(domain, registrar)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Register →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<span className="strategy">
|
||||
{idea.strategy === "ai" ? <span className="ai-badge">AI</span> : null} {strategyLabel(idea.strategy)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusIcon(status: Availability | "checking"): string {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return "●";
|
||||
case "unverified-available":
|
||||
return "◐";
|
||||
case "taken":
|
||||
return "○";
|
||||
case "checking":
|
||||
return "…";
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: Availability | "checking"): string {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return "free";
|
||||
case "unverified-available":
|
||||
return "probably free";
|
||||
case "taken":
|
||||
return "taken";
|
||||
case "checking":
|
||||
return "checking";
|
||||
default:
|
||||
return "unclear";
|
||||
}
|
||||
}
|
||||
|
||||
export function strategyLabel(strategy: Idea["strategy"]): string {
|
||||
switch (strategy) {
|
||||
case "compound":
|
||||
return "compound";
|
||||
case "suffix-word":
|
||||
return "compound";
|
||||
case "action":
|
||||
return "call to action";
|
||||
case "blend":
|
||||
return "blend";
|
||||
case "coined":
|
||||
return "coined";
|
||||
case "clipped":
|
||||
return "clipped";
|
||||
case "root":
|
||||
return "classical root";
|
||||
case "hack":
|
||||
return "domain hack";
|
||||
case "ai":
|
||||
return "generated";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
105
src/components/Wishlist.tsx
Normal file
105
src/components/Wishlist.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
// The Saved panel: shortlist cards with their last-seen statuses, Re-check and
|
||||
// Copy list. State comes from hooks/useWishlist.
|
||||
|
||||
import type { SavedName } from "@/lib/wishlist";
|
||||
import { registerUrl } from "@/lib/registrars";
|
||||
import type { Availability } from "@/lib/availability-client";
|
||||
import { statusIcon, statusLabel } from "./NameCard";
|
||||
|
||||
export function WishlistPanel({
|
||||
saved,
|
||||
registrar,
|
||||
rechecking,
|
||||
copied,
|
||||
onRecheck,
|
||||
onCopy,
|
||||
onRemove,
|
||||
}: {
|
||||
saved: SavedName[];
|
||||
registrar: string;
|
||||
rechecking: boolean;
|
||||
copied: boolean;
|
||||
onRecheck: () => void;
|
||||
onCopy: () => void;
|
||||
onRemove: (name: string) => void;
|
||||
}) {
|
||||
if (saved.length === 0) return null;
|
||||
return (
|
||||
<section className="wishlist" aria-label="Saved names">
|
||||
<div className="wishlist-head">
|
||||
<h2>
|
||||
Saved <span className="count">{saved.length}</span>
|
||||
</h2>
|
||||
<div className="wishlist-actions">
|
||||
<button type="button" className="mini" onClick={onRecheck} disabled={rechecking}>
|
||||
{rechecking ? "Checking…" : "Re-check"}
|
||||
</button>
|
||||
<button type="button" className="mini" onClick={onCopy}>
|
||||
{copied ? "Copied" : "Copy list"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint">
|
||||
Kept in this browser only. Statuses are from the last time each name was checked — press
|
||||
Re-check before you commit.
|
||||
</p>
|
||||
<div className="results">
|
||||
{saved.map((entry) => (
|
||||
<SavedCard key={entry.name} entry={entry} registrar={registrar} onRemove={() => onRemove(entry.name)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedCard({
|
||||
entry,
|
||||
registrar,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: SavedName;
|
||||
registrar: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const isHack = entry.domains.length === 1 && entry.domains[0] === entry.name;
|
||||
const settled = entry.domains.map((d) => entry.checks[d]).filter(Boolean);
|
||||
const allTaken = settled.length === entry.domains.length && settled.every((c) => c.status === "taken");
|
||||
return (
|
||||
<div className={`card is-saved${allTaken ? " is-taken" : ""}`}>
|
||||
<div className="card-head">
|
||||
<div className="card-name">{entry.name}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="save"
|
||||
aria-pressed
|
||||
title="Remove from saved"
|
||||
aria-label={`Remove ${entry.name} from saved`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
</div>
|
||||
{entry.domains.map((domain) => {
|
||||
const check = entry.checks[domain];
|
||||
const status: Availability = check?.status ?? "unknown";
|
||||
const free = status === "available" || status === "unverified-available";
|
||||
return (
|
||||
<div className="card-meta" key={domain}>
|
||||
<span className={`status ${status}`} title={check?.detail}>
|
||||
{statusIcon(status)} {isHack ? domain : `.${domain.slice(entry.name.length + 1)}`}{" "}
|
||||
<span style={{ fontWeight: 400 }}>{statusLabel(status)}</span>
|
||||
</span>
|
||||
{free ? (
|
||||
<a className="register-link" href={registerUrl(domain, registrar)} target="_blank" rel="noopener noreferrer">
|
||||
Register →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/hooks/useOtmSession.ts
Normal file
51
src/hooks/useOtmSession.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
// Who's signed in with OTM (null = nobody), whether this deployment has the
|
||||
// platform AI gateway wired at all, and the note the SSO callback bounced back
|
||||
// with (?signin=<reason>) if the last sign-in attempt failed.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface OtmAccount {
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export function useOtmSession() {
|
||||
const [account, setAccount] = useState<OtmAccount | null>(null);
|
||||
const [platformAi, setPlatformAi] = useState(false);
|
||||
const [signinNote, setSigninNote] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch("/api/auth/session", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((b: { platformAi?: boolean; user?: OtmAccount | null } | null) => {
|
||||
if (!alive || !b) return;
|
||||
setPlatformAi(Boolean(b.platformAi));
|
||||
setAccount(b.user ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
const reason = new URLSearchParams(window.location.search).get("signin");
|
||||
if (reason) {
|
||||
setSigninNote(
|
||||
reason === "link_already_used"
|
||||
? "That sign-in link was already used — try again."
|
||||
: reason === "not_enabled"
|
||||
? "Sign-in isn't enabled on this deployment."
|
||||
: "Sign-in didn't complete — try again.",
|
||||
);
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const signOut = async () => {
|
||||
await fetch("/api/auth/signout", { method: "POST" }).catch(() => {});
|
||||
setAccount(null);
|
||||
};
|
||||
|
||||
return { account, platformAi, signinNote, signOut };
|
||||
}
|
||||
107
src/hooks/useWishlist.ts
Normal file
107
src/hooks/useWishlist.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
// The shortlist's state: localStorage-backed list, snapshot sync from live
|
||||
// lookups, save/remove, re-check and copy. UI lives in components/Wishlist.tsx.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BATCH, fetchAvailability, type DomainCheck } from "@/lib/availability-client";
|
||||
import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
|
||||
|
||||
function snapshotOf(c: DomainCheck): SavedName["checks"][string] {
|
||||
return { status: c.status, method: c.method, detail: c.detail };
|
||||
}
|
||||
|
||||
export function useWishlist(checks: Record<string, DomainCheck>, onError: (msg: string | null) => void) {
|
||||
const [saved, setSaved] = useState<SavedName[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Read once on mount, write on every change after that. The `loaded` gate
|
||||
// stops the first (empty) render from clobbering storage.
|
||||
useEffect(() => {
|
||||
setSaved(loadWishlist());
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (loaded) saveWishlist(saved);
|
||||
}, [saved, loaded]);
|
||||
|
||||
// Keep snapshots current: whenever a lookup resolves a domain that's on the
|
||||
// shortlist, copy the fresh status in — a name saved while still "checking"
|
||||
// ends up with a real answer.
|
||||
useEffect(() => {
|
||||
setSaved((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((e) => {
|
||||
let entry = e;
|
||||
for (const d of e.domains) {
|
||||
const c = checks[d];
|
||||
if (!c) continue;
|
||||
const old = entry.checks[d];
|
||||
if (old && old.status === c.status && old.detail === c.detail) continue;
|
||||
entry = { ...entry, checks: { ...entry.checks, [d]: snapshotOf(c) } };
|
||||
changed = true;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [checks]);
|
||||
|
||||
const savedNames = useMemo(() => new Set(saved.map((e) => e.name)), [saved]);
|
||||
|
||||
const toggleSaved = useCallback(
|
||||
(name: string, domains: string[]) => {
|
||||
setSaved((prev) => {
|
||||
if (prev.some((e) => e.name === name)) return prev.filter((e) => e.name !== name);
|
||||
if (prev.length >= WISHLIST_MAX) return prev;
|
||||
const snapshot: SavedName["checks"] = {};
|
||||
for (const d of domains) if (checks[d]) snapshot[d] = snapshotOf(checks[d]);
|
||||
return [{ name, domains, checks: snapshot, savedAt: new Date().toISOString() }, ...prev];
|
||||
});
|
||||
},
|
||||
[checks],
|
||||
);
|
||||
|
||||
const removeSaved = useCallback((name: string) => setSaved((prev) => prev.filter((e) => e.name !== name)), []);
|
||||
|
||||
/** Re-query every saved domain. Independent of the finder's run cancellation —
|
||||
* a shortlist re-check shouldn't die because the visitor hit "Show me more". */
|
||||
const recheckSaved = async () => {
|
||||
const domains = Array.from(new Set(saved.flatMap((e) => e.domains)));
|
||||
if (domains.length === 0) return;
|
||||
setRechecking(true);
|
||||
onError(null);
|
||||
try {
|
||||
for (let i = 0; i < domains.length; i += BATCH) {
|
||||
const results = await fetchAvailability(domains.slice(i, i + BATCH));
|
||||
const fresh: Record<string, DomainCheck> = {};
|
||||
for (const r of results) fresh[r.domain] = r;
|
||||
setSaved((prev) =>
|
||||
prev.map((e) => {
|
||||
const nextChecks = { ...e.checks };
|
||||
for (const d of e.domains) if (fresh[d]) nextChecks[d] = snapshotOf(fresh[d]);
|
||||
return { ...e, checks: nextChecks };
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Could not reach the availability service.");
|
||||
} finally {
|
||||
setRechecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copySaved = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wishlistAsText(saved));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
onError("Couldn't copy — your browser blocked clipboard access.");
|
||||
}
|
||||
};
|
||||
|
||||
return { saved, savedNames, toggleSaved, removeSaved, recheckSaved, copySaved, rechecking, copied };
|
||||
}
|
||||
51
src/lib/availability-client.ts
Normal file
51
src/lib/availability-client.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Browser-side helpers for the availability route: the result shape, the
|
||||
// batch size, and the two small pure functions both the finder and the
|
||||
// wishlist need. Server logic lives in app/api/availability + lib/rdap.ts.
|
||||
|
||||
import type { Idea } from "@/lib/generate";
|
||||
|
||||
export type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||
|
||||
export interface DomainCheck {
|
||||
domain: string;
|
||||
status: Availability;
|
||||
method: "rdap" | "dns" | "none";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Domains per availability request. The route caps at 60; staying under it
|
||||
* means results paint in progressively instead of in one late lump. */
|
||||
export const BATCH = 40;
|
||||
|
||||
/** Every domain a set of ideas implies, in display order. */
|
||||
export function domainsFor(list: Idea[], selected: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const idea of list) {
|
||||
if (idea.hackTld && idea.hackStem) {
|
||||
out.push(`${idea.hackStem}.${idea.hackTld}`);
|
||||
continue;
|
||||
}
|
||||
for (const tld of selected) out.push(`${idea.name}.${tld}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One batch against /api/availability. Throws with a message fit to show. */
|
||||
export async function fetchAvailability(domains: string[]): Promise<DomainCheck[]> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("/api/availability", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ domains }),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not reach the availability service.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `Availability lookup failed (${res.status}).`);
|
||||
}
|
||||
const body = (await res.json()) as { results: DomainCheck[] };
|
||||
return body.results;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
|
||||
// change. Same convention as rpo.
|
||||
|
||||
export const VERSION = "0.4.0";
|
||||
export const VERSION = "0.4.1";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -11,6 +11,11 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.4.1",
|
||||
date: "2026-08-16",
|
||||
changes: ["Internal: split the finder into focused components/hooks. No visible change."],
|
||||
},
|
||||
{
|
||||
version: "0.4.0",
|
||||
date: "2026-08-16",
|
||||
|
||||
Reference in New Issue
Block a user