- ☆ on every card saves it to a browser-only shortlist (localStorage names.wishlist, cap 60) shown as a Saved panel with snapshotted statuses, Re-check and Copy list. - SuggestionLightbulb from @otm/account-panel is Tailwind-styled and names had no Tailwind, so it rendered as a bare button. Added Tailwind (preflight off, dark class pinned), scoped element CSS to .wrap and mounted the widget outside it. - v0.2.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -76,6 +76,24 @@ brings that output straight back:
|
|||||||
`diversify()` caps each strategy at ~28% of the list. Without it the top 20 came
|
`diversify()` caps each strategy at ~28% of the list. Without it the top 20 came
|
||||||
back as eighteen coinages — raw score ranking clumps badly.
|
back as eighteen coinages — raw score ranking clumps badly.
|
||||||
|
|
||||||
|
## Shared OTM widgets need Tailwind
|
||||||
|
|
||||||
|
The bottom-right feedback chat-pop is `SuggestionLightbulb` from
|
||||||
|
`@otm/account-panel`, styled with Tailwind utilities. This app's own UI is plain
|
||||||
|
CSS, and for a while it had no Tailwind at all — so the widget rendered as a
|
||||||
|
bare unstyled button (issue #2). Tailwind is configured with `preflight: false`
|
||||||
|
(it must not reset the handwritten CSS) and its `content` includes
|
||||||
|
`node_modules/@otm/account-panel/src`. Element-level rules in `globals.css` are
|
||||||
|
scoped to `.wrap`, and the widget mounts *outside* `.wrap`, so neither side can
|
||||||
|
restyle the other. Keep it that way when adding global CSS.
|
||||||
|
|
||||||
|
## Wishlist is browser-only
|
||||||
|
|
||||||
|
`src/lib/wishlist.ts` — `localStorage` key `names.wishlist`, capped at 60. It
|
||||||
|
snapshots the last-seen status per domain; the Finder keeps snapshots fresh from
|
||||||
|
live lookups and **Re-check** re-queries through the normal availability route.
|
||||||
|
No server state, on purpose.
|
||||||
|
|
||||||
## Keys stay in the browser
|
## Keys stay in the browser
|
||||||
|
|
||||||
Optional AI names use the **visitor's own** Anthropic/Gemini key from
|
Optional AI names use the **visitor's own** Anthropic/Gemini key from
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -71,6 +71,16 @@ shouldn't hold a metered credential.
|
|||||||
The `anthropic-dangerous-direct-browser-access` header is correct here and would
|
The `anthropic-dangerous-direct-browser-access` header is correct here and would
|
||||||
be wrong in a product that owns the key. The distinction is whose key it is.
|
be wrong in a product that owns the key. The distinction is whose key it is.
|
||||||
|
|
||||||
|
## Saved names
|
||||||
|
|
||||||
|
Every card has a ☆. Pressing it keeps the name in a **Saved** panel above the
|
||||||
|
results, so you can shortlist while you keep generating. The list is
|
||||||
|
`localStorage` only (`names.wishlist`, capped at 60) — there are no accounts and
|
||||||
|
this doesn't need one. Each entry snapshots the availability it last saw, and
|
||||||
|
the panel's **Re-check** button re-queries every saved domain (batched through
|
||||||
|
the same `/api/availability` route, so it obeys the same manners) and **Copy
|
||||||
|
list** puts a plain-text `domain — status` list on the clipboard.
|
||||||
|
|
||||||
## Registrar links
|
## Registrar links
|
||||||
|
|
||||||
`src/lib/registrars.ts`. Links are plain today — every `affiliateCode` is `""`.
|
`src/lib/registrars.ts`. Links are plain today — every `affiliateCode` is `""`.
|
||||||
@@ -87,6 +97,10 @@ npm run typecheck
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
No env vars are needed to run it. The suggestions lightbulb degrades to a quiet
|
No env vars are needed to run it. Tailwind is present **only** to style the
|
||||||
|
shared `@otm/account-panel` widgets (the bottom-right feedback chat-pop) — the
|
||||||
|
app's own UI is handwritten CSS, so `preflight` is off and the config scans
|
||||||
|
`node_modules/@otm/account-panel/src`. The app is always dark, so `<html
|
||||||
|
class="dark">` pins the widget's `dark:` variants on. The suggestions lightbulb degrades to a quiet
|
||||||
"not configured" state unless `GITEA_URL` / `GITEA_TOKEN` / `GITEA_REPO` are set
|
"not configured" state unless `GITEA_URL` / `GITEA_TOKEN` / `GITEA_REPO` are set
|
||||||
(the platform injects those at deploy time).
|
(the platform injects those at deploy time).
|
||||||
|
|||||||
1148
package-lock.json
generated
1148
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "names-poweredbyotm",
|
"name": "names-poweredbyotm",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3000",
|
"dev": "next dev --port 3000",
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"@types/node": "^22.9.1",
|
"@types/node": "^22.9.1",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
postcss.config.mjs
Normal file
3
postcss.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0d10;
|
--bg: #0b0d10;
|
||||||
--panel: #14181d;
|
--panel: #14181d;
|
||||||
@@ -88,10 +91,13 @@ h2 {
|
|||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
/* Element rules are scoped to .wrap so they can't bleed into the shared
|
||||||
input[type="password"],
|
OTM widgets (SuggestionLightbulb) mounted outside it, which style
|
||||||
textarea,
|
themselves with Tailwind utilities. */
|
||||||
select {
|
.wrap input[type="text"],
|
||||||
|
.wrap input[type="password"],
|
||||||
|
.wrap textarea,
|
||||||
|
.wrap select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -103,9 +109,9 @@ select {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
.wrap input:focus,
|
||||||
textarea:focus,
|
.wrap textarea:focus,
|
||||||
select:focus {
|
.wrap select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
}
|
}
|
||||||
@@ -117,7 +123,7 @@ select:focus {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.wrap button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -128,23 +134,23 @@ button {
|
|||||||
padding: 11px 18px;
|
padding: 11px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
.wrap button:hover:not(:disabled) {
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
.wrap button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
.wrap button.primary {
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary:hover:not(:disabled) {
|
.wrap button.primary:hover:not(:disabled) {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -254,6 +260,89 @@ button.primary:hover:not(:disabled) {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save star: bare glyph, no chrome. Filled + accent when saved. */
|
||||||
|
.wrap button.save {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--faint);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap button.save:hover:not(:disabled) {
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap button.save[aria-pressed="true"] {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-saved {
|
||||||
|
border-color: #4a3f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- wishlist ---------- */
|
||||||
|
|
||||||
|
.wishlist {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
border: 1px solid #4a3f1c;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #17150f, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist h2 .count {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 22px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap button.mini {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .results {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.strategy {
|
.strategy {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<body>
|
<body>
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
|
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
|
||||||
Powered by OTM
|
Powered by OTM
|
||||||
</Link>
|
</Link>
|
||||||
<SuggestionLightbulb />
|
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
@@ -41,6 +40,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Shared OTM feedback chat-pop (bottom-right). Mounted outside .wrap
|
||||||
|
so the app's scoped element CSS can't restyle it. */}
|
||||||
|
<SuggestionLightbulb />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { generateNames, scoreName, type Idea, type Style } from "@/lib/generate"
|
|||||||
import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
|
import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
|
||||||
import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
|
import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
|
||||||
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
|
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
|
||||||
|
import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
|
||||||
|
|
||||||
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||||
|
|
||||||
@@ -44,6 +45,113 @@ export default function Finder() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [ai, setAi] = useState<AiSettings | null>(null);
|
const [ai, setAi] = useState<AiSettings | null>(null);
|
||||||
const [useAi, setUseAi] = useState(false);
|
const [useAi, setUseAi] = 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.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// The saved key is read once on mount; Settings lives on its own page, so it
|
// The saved key is read once on mount; Settings lives on its own page, so it
|
||||||
// can't change underneath us mid-session.
|
// can't change underneath us mid-session.
|
||||||
@@ -283,6 +391,33 @@ export default function Finder() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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}
|
||||||
|
|
||||||
{ideas.length === 0 ? (
|
{ideas.length === 0 ? (
|
||||||
<p className="empty">
|
<p className="empty">
|
||||||
{busy ? "Working…" : "Describe the business above and press Find names."}
|
{busy ? "Working…" : "Describe the business above and press Find names."}
|
||||||
@@ -297,6 +432,8 @@ export default function Finder() {
|
|||||||
checks={checks}
|
checks={checks}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
registrar={registrar}
|
registrar={registrar}
|
||||||
|
saved={savedNames.has(idea.hackTld && idea.hackStem ? `${idea.hackStem}.${idea.hackTld}` : idea.name)}
|
||||||
|
onToggleSave={toggleSaved}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -311,31 +448,48 @@ function NameCard({
|
|||||||
checks,
|
checks,
|
||||||
pending,
|
pending,
|
||||||
registrar,
|
registrar,
|
||||||
|
saved,
|
||||||
|
onToggleSave,
|
||||||
}: {
|
}: {
|
||||||
idea: Idea;
|
idea: Idea;
|
||||||
tlds: string[];
|
tlds: string[];
|
||||||
checks: Record<string, DomainCheck>;
|
checks: Record<string, DomainCheck>;
|
||||||
pending: Set<string>;
|
pending: Set<string>;
|
||||||
registrar: string;
|
registrar: string;
|
||||||
|
saved: boolean;
|
||||||
|
onToggleSave: (name: string, domains: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const isHack = Boolean(idea.hackTld && idea.hackStem);
|
const isHack = Boolean(idea.hackTld && idea.hackStem);
|
||||||
const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
|
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.
|
// Dim the whole card only when every extension we checked came back taken.
|
||||||
const settled = domains.map((d) => checks[d]).filter(Boolean);
|
const settled = domains.map((d) => checks[d]).filter(Boolean);
|
||||||
const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
|
const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card${allTaken ? " is-taken" : ""}`}>
|
<div className={`card${allTaken ? " is-taken" : ""}${saved ? " is-saved" : ""}`}>
|
||||||
<div className="card-name">
|
<div className="card-head">
|
||||||
{isHack ? (
|
<div className="card-name">
|
||||||
<>
|
{isHack ? (
|
||||||
{idea.hackStem}
|
<>
|
||||||
<span className="tld">.{idea.hackTld}</span>
|
{idea.hackStem}
|
||||||
</>
|
<span className="tld">.{idea.hackTld}</span>
|
||||||
) : (
|
</>
|
||||||
idea.name
|
) : (
|
||||||
)}
|
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>
|
</div>
|
||||||
|
|
||||||
{domains.map((domain) => {
|
{domains.map((domain) => {
|
||||||
@@ -369,6 +523,55 @@ function NameCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function statusIcon(status: Availability | "checking"): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "available":
|
case "available":
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
|
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
|
||||||
// change. Same convention as rpo.
|
// change. Same convention as rpo.
|
||||||
|
|
||||||
export const VERSION = "0.1.0";
|
export const VERSION = "0.2.0";
|
||||||
|
|
||||||
export interface ChangelogEntry {
|
export interface ChangelogEntry {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.2.0",
|
||||||
|
date: "2026-08-16",
|
||||||
|
changes: [
|
||||||
|
"Saved names: press ☆ on any card to keep it in a shortlist while you keep looking. The list lives in your browser, remembers the availability it last saw, and has Re-check and Copy list buttons.",
|
||||||
|
"The feedback widget (bottom-right) is now properly styled — it was rendering as a bare unstyled button.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
date: "2026-08-10",
|
date: "2026-08-10",
|
||||||
|
|||||||
68
src/lib/wishlist.ts
Normal file
68
src/lib/wishlist.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// The visitor's shortlist. Lives entirely in localStorage — this app has no
|
||||||
|
// accounts and no DB, and a shortlist you're still mulling over is exactly the
|
||||||
|
// kind of thing that shouldn't need one. Each entry snapshots the availability
|
||||||
|
// we last saw so the list still means something after the results it came
|
||||||
|
// from have been regenerated away.
|
||||||
|
|
||||||
|
export type SavedStatus = "available" | "taken" | "unverified-available" | "unknown";
|
||||||
|
|
||||||
|
export interface SavedCheck {
|
||||||
|
status: SavedStatus;
|
||||||
|
method: "rdap" | "dns" | "none";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedName {
|
||||||
|
/** The bare name (for a domain hack, the full stem+tld, e.g. "stud.io"). */
|
||||||
|
name: string;
|
||||||
|
/** Domains that were on the card when it was saved, in display order. */
|
||||||
|
domains: string[];
|
||||||
|
/** Last-known status per domain. Missing = never resolved. */
|
||||||
|
checks: Record<string, SavedCheck>;
|
||||||
|
savedAt: string; // ISO
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "names.wishlist";
|
||||||
|
export const WISHLIST_MAX = 60;
|
||||||
|
|
||||||
|
export function loadWishlist(): SavedName[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter(
|
||||||
|
(e): e is SavedName =>
|
||||||
|
!!e && typeof e === "object" && typeof (e as SavedName).name === "string" && Array.isArray((e as SavedName).domains),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWishlist(list: SavedName[]): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, JSON.stringify(list.slice(0, WISHLIST_MAX)));
|
||||||
|
} catch {
|
||||||
|
// Quota / private mode — the in-memory list still works for the session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text export: one line per domain with its last-known status. */
|
||||||
|
export function wishlistAsText(list: SavedName[]): string {
|
||||||
|
const label: Record<SavedStatus, string> = {
|
||||||
|
available: "available",
|
||||||
|
"unverified-available": "probably free",
|
||||||
|
taken: "taken",
|
||||||
|
unknown: "unchecked",
|
||||||
|
};
|
||||||
|
return list
|
||||||
|
.flatMap((e) =>
|
||||||
|
e.domains.length
|
||||||
|
? e.domains.map((d) => `${d} — ${label[e.checks[d]?.status ?? "unknown"]}`)
|
||||||
|
: [e.name],
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
14
tailwind.config.ts
Normal file
14
tailwind.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
// Tailwind exists here ONLY to style the shared @otm/account-panel widgets
|
||||||
|
// (SuggestionLightbulb — the bottom-right feedback chat-pop). The app's own UI
|
||||||
|
// is handwritten CSS in globals.css, so preflight is off: Tailwind must not
|
||||||
|
// reset anything, just supply the utility classes the shared components use.
|
||||||
|
// The app is always dark, so dark: variants are pinned on via <html class="dark">.
|
||||||
|
export default {
|
||||||
|
content: ["./src/**/*.{ts,tsx}", "./node_modules/@otm/account-panel/src/**/*.{ts,tsx}"],
|
||||||
|
darkMode: "class",
|
||||||
|
corePlugins: { preflight: false },
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
Reference in New Issue
Block a user