Skip to content

Commit a413ff1

Browse files
committed
feat(handle-console): add handle management components and hooks
- Implemented HandleRequestPreviewPanel for displaying request previews. - Created HandleSuccessDialog for showing success messages after actions. - Developed RemoveTabPanel for handling the removal of handles. - Introduced useHandleConsoleController hook for managing handle-related state and actions. - Added handle-console.service for API interactions related to handle management. - Defined types for API responses and handle console operations in handle-console.types. - Implemented utility functions for handle validation and cURL command generation in handle-console.utils.
1 parent eb4cb25 commit a413ff1

16 files changed

Lines changed: 2014 additions & 10 deletions

app/(public)/handle/page.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { Fingerprint } from "lucide-react";
5+
import { SiteHeader } from "@/components/site-header";
6+
import { HandleCard } from "@/components/handle-card";
7+
8+
type ApiStatus = "idle" | "connected" | "error";
9+
10+
export default function HandlePage() {
11+
const [apiStatus, setApiStatus] = React.useState<ApiStatus>("idle");
12+
13+
return (
14+
<>
15+
<SiteHeader onApiStatusChange={setApiStatus} />
16+
17+
<main className="relative mx-auto max-w-[960px] px-4 pt-24 pb-28 sm:px-6 sm:pt-36 sm:pb-32">
18+
<section className="mx-auto max-w-[840px] space-y-6 sm:space-y-8">
19+
{/* ── Handle feature announcement ── */}
20+
<div className="stats-card group relative overflow-hidden rounded-2xl border border-[var(--glass-border)] bg-[var(--glass-bg)] px-5 py-4 backdrop-blur-[var(--glass-blur)] backdrop-saturate-[1.3] transition-all duration-300 ease-[var(--motion-ease-standard)] hover:scale-[1.01]">
21+
<div
22+
className="pointer-events-none absolute inset-0 rounded-2xl opacity-[0.035] transition-opacity duration-500 group-hover:opacity-[0.07]"
23+
style={{ background: "radial-gradient(ellipse 80% 60% at 20% 40%, rgb(var(--alias-accent-rgb) / 1), transparent)" }}
24+
/>
25+
<div className="pointer-events-none absolute top-0 left-[15%] right-[15%] h-px rounded-b bg-gradient-to-r from-transparent via-[rgb(var(--alias-accent-rgb)_/_0.30)] to-transparent opacity-60" />
26+
27+
<div className="relative flex items-start gap-4">
28+
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[rgb(var(--alias-accent-rgb)_/_0.12)] bg-[rgb(var(--alias-accent-rgb)_/_0.08)] shadow-[0_0_12px_rgb(var(--alias-accent-rgb)_/_0.06)]">
29+
<Fingerprint className="h-[18px] w-[18px] text-[var(--alias-accent)] opacity-80" />
30+
</div>
31+
<div className="min-w-0 space-y-1">
32+
<p className="flex items-center gap-2 text-[13px] font-semibold tracking-tight text-[var(--text-primary)]">
33+
Handles are live
34+
<span className="rounded bg-[rgb(var(--alias-accent-rgb)_/_0.15)] px-1.5 py-0.5 text-[9px] font-bold leading-none tracking-wide text-[var(--neu-green)] uppercase">
35+
New
36+
</span>
37+
</p>
38+
<p className="text-xs leading-relaxed text-[var(--text-secondary)]">
39+
Claim a unique username and receive emails at <span className="font-mono text-[var(--text-primary)]">you@*</span> across
40+
every domain. Block specific domains, keep the rest &mdash; your handle, your rules.
41+
</p>
42+
</div>
43+
</div>
44+
</div>
45+
46+
<HandleCard apiStatus={apiStatus} onApiStatusChange={setApiStatus} />
47+
48+
<footer className="mt-12 flex justify-center pb-8 sm:mt-16 sm:pb-12">
49+
<div
50+
className="relative inline-flex items-center gap-3 rounded-full border border-[var(--glass-border)] bg-[var(--glass-bg)] px-5 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_4px_16px_-4px_rgba(0,0,0,0.30)] backdrop-blur-[24px] backdrop-saturate-[1.3]"
51+
>
52+
<div className="absolute inset-0 pointer-events-none rounded-full" />
53+
<div className="relative flex items-center gap-2.5">
54+
<span className="relative flex h-2 w-2">
55+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--neu-green)] opacity-30"></span>
56+
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--neu-green)] opacity-70 shadow-[0_0_6px_rgba(var(--neu-green-rgb)/0.5)]"></span>
57+
</span>
58+
<p className="m-0 font-sans text-[11px] font-medium tracking-wider text-[var(--text-secondary)] uppercase">
59+
Powered by{" "}
60+
<a href="https://haltman.io" target="_blank" rel="noreferrer" className="font-bold text-[color:var(--text-primary)] transition-colors hover:text-[var(--neu-green)]">Haltman.io</a>
61+
<span className="mx-1.5 text-[color:var(--text-muted)]">&amp;</span>
62+
<a href="https://www.thc.org" target="_blank" rel="noreferrer" className="font-bold text-[color:var(--text-primary)] transition-colors hover:text-[var(--neu-green)]">The Hacker&apos;s Choice</a>
63+
</p>
64+
</div>
65+
</div>
66+
</footer>
67+
</section>
68+
</main>
69+
</>
70+
);
71+
}

components/console-menu.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { Fingerprint, Mail, Terminal } from "lucide-react";
5+
6+
import {
7+
NavGroupMenu,
8+
navMenuItemClassName,
9+
navMenuItemIconClassName,
10+
} from "@/components/nav-group-menu";
11+
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
12+
13+
type ConsoleMenuProps = {
14+
triggerClassName?: string;
15+
triggerIconClassName?: string;
16+
open?: boolean;
17+
onOpenChange?: (open: boolean) => void;
18+
};
19+
20+
const newBadge = (
21+
<span className="ml-auto rounded bg-[rgb(var(--alias-accent-rgb)_/_0.15)] px-1.5 py-0.5 text-[9px] font-bold leading-none tracking-wide text-[var(--neu-green)] uppercase">
22+
New
23+
</span>
24+
);
25+
26+
export function ConsoleMenu({
27+
triggerClassName,
28+
triggerIconClassName,
29+
open,
30+
onOpenChange,
31+
}: ConsoleMenuProps) {
32+
return (
33+
<NavGroupMenu
34+
label="Console"
35+
icon={Terminal}
36+
badge={newBadge}
37+
triggerClassName={triggerClassName}
38+
triggerIconClassName={triggerIconClassName}
39+
open={open}
40+
onOpenChange={onOpenChange}
41+
>
42+
<DropdownMenuItem asChild className={navMenuItemClassName}>
43+
<Link href="/console">
44+
<Mail className={navMenuItemIconClassName} />
45+
Alias
46+
</Link>
47+
</DropdownMenuItem>
48+
<DropdownMenuItem asChild className={navMenuItemClassName}>
49+
<Link href="/handle">
50+
<Fingerprint className={navMenuItemIconClassName} />
51+
Handle
52+
{newBadge}
53+
</Link>
54+
</DropdownMenuItem>
55+
</NavGroupMenu>
56+
);
57+
}

components/handle-card.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"use client";
2+
3+
import { HandleConsoleCard } from "@/features/handle-console/components/handle-console-card";
4+
import type { HandleConsoleCardProps } from "@/features/handle-console/types/handle-console.types";
5+
6+
export function HandleCard(props: HandleConsoleCardProps = {}) {
7+
return <HandleConsoleCard {...props} />;
8+
}

components/nav-group-menu.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type NavGroupMenuProps = {
1414
label: string;
1515
children: React.ReactNode;
1616
icon?: LucideIcon;
17+
badge?: React.ReactNode;
1718
triggerClassName?: string;
1819
triggerIconClassName?: string;
1920
contentClassName?: string;
@@ -32,6 +33,7 @@ export function NavGroupMenu({
3233
label,
3334
children,
3435
icon: Icon,
36+
badge,
3537
triggerClassName,
3638
triggerIconClassName,
3739
contentClassName,
@@ -60,6 +62,7 @@ export function NavGroupMenu({
6062
/>
6163
) : null}
6264
{label}
65+
{badge}
6366
<ChevronDown
6467
className={cn(
6568
"ml-0.5 h-3.5 w-3.5 transition-transform duration-200 group-data-[state=open]:rotate-180",

components/site-header.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
Settings,
3232
User
3333
} from "lucide-react";
34+
import { ConsoleMenu } from "@/components/console-menu";
3435
import { SetupMenu } from "@/components/setup-menu";
3536
import { ApiMenu } from "@/components/api-menu";
3637
import { DocsMenu } from "@/components/docs-menu";
@@ -296,10 +297,12 @@ export function SiteHeader({ onApiStatusChange }: SiteHeaderProps = {}) {
296297
>
297298
{isMobileViewport === false ? (
298299
<>
299-
<Link href="/console" className={navItemClass}>
300-
<Terminal className="h-4 w-4 text-[var(--text-secondary)]" />
301-
Console
302-
</Link>
300+
<ConsoleMenu
301+
triggerClassName={navItemClass}
302+
triggerIconClassName={navIconClass}
303+
open={openDesktopMenu === "console"}
304+
onOpenChange={handleDesktopMenuOpenChange("console")}
305+
/>
303306
<SetupMenu
304307
triggerClassName={navItemClass}
305308
triggerIconClassName={navIconClass}
@@ -352,12 +355,21 @@ export function SiteHeader({ onApiStatusChange }: SiteHeaderProps = {}) {
352355
className="max-h-[80vh] w-[min(92vw,320px)] overflow-y-auto p-2"
353356
>
354357
<div className="space-y-0.5">
355-
{/* ── L1: Console ── */}
356-
<Link href="/console" className={mobileNavItemClassName} onClick={closeMobileMenu}>
357-
<Terminal className={mobileNavItemIconClassName} />
358-
Console
359-
<MobileNavLinkIndicator />
360-
</Link>
358+
{/* ── L1: Console (expandable) ── */}
359+
<MobileNavSection label="Console">
360+
<Link href="/console" className={mobileNavL2ClassName} onClick={closeMobileMenu}>
361+
<Terminal className={mobileNavL2IconClassName} />
362+
Alias
363+
<MobileNavLinkIndicator />
364+
</Link>
365+
<Link href="/handle" className={mobileNavL2ClassName} onClick={closeMobileMenu}>
366+
<Fingerprint className={mobileNavL2IconClassName} />
367+
Handle
368+
<span className="ml-auto rounded bg-[rgb(var(--alias-accent-rgb)_/_0.15)] px-1.5 py-0.5 text-[9px] font-bold leading-none tracking-wide text-[var(--neu-green)] uppercase">
369+
New
370+
</span>
371+
</Link>
372+
</MobileNavSection>
361373

362374
<MobileNavSeparator />
363375

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { FormEvent, ReactNode } from "react";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Input } from "@/components/ui/input";
5+
import { Label } from "@/components/ui/label";
6+
import { TabsContent } from "@/components/ui/tabs";
7+
8+
import { HandleRequestPreviewPanel } from "@/features/handle-console/components/handle-request-preview-panel";
9+
import type {
10+
HandleSnapshot,
11+
PreviewSummaryItem,
12+
RequestPreviewState,
13+
} from "@/features/handle-console/types/handle-console.types";
14+
15+
type ClaimTabPanelProps = {
16+
handle: string;
17+
to: string;
18+
requestBusy: boolean;
19+
claimButtonContent: ReactNode;
20+
showConfirmedPanel: boolean;
21+
confirmedSnapshot: HandleSnapshot | null;
22+
claimAwaiting: boolean;
23+
claimHasInput: boolean;
24+
claimReady: boolean;
25+
onHandleChange: (value: string) => void;
26+
onToChange: (value: string) => void;
27+
onSubmit: (e: FormEvent) => void;
28+
onViewCurl: () => void;
29+
};
30+
31+
export function ClaimTabPanel({
32+
handle,
33+
to,
34+
requestBusy,
35+
claimButtonContent,
36+
showConfirmedPanel,
37+
confirmedSnapshot,
38+
claimAwaiting,
39+
claimHasInput,
40+
claimReady,
41+
onHandleChange,
42+
onToChange,
43+
onSubmit,
44+
onViewCurl,
45+
}: ClaimTabPanelProps) {
46+
const previewState: RequestPreviewState = showConfirmedPanel
47+
? "confirmed"
48+
: claimAwaiting
49+
? "awaiting_confirmation"
50+
: claimHasInput
51+
? "draft"
52+
: "empty";
53+
54+
const previewSummaryItems: PreviewSummaryItem[] = [
55+
{
56+
label: "Action",
57+
value: "Claim handle",
58+
tone: "accent",
59+
},
60+
{
61+
label: "Handle",
62+
value:
63+
showConfirmedPanel && confirmedSnapshot?.handle
64+
? confirmedSnapshot.handle
65+
: handle.trim() || "{handle}",
66+
mono: true,
67+
tone:
68+
showConfirmedPanel && confirmedSnapshot?.handle
69+
? "accent"
70+
: handle.trim()
71+
? "accent"
72+
: "muted",
73+
},
74+
{
75+
label: "Destination",
76+
value:
77+
showConfirmedPanel && confirmedSnapshot?.to
78+
? confirmedSnapshot.to
79+
: to.trim() || "[email protected]",
80+
mono: true,
81+
tone:
82+
showConfirmedPanel && confirmedSnapshot?.to
83+
? "default"
84+
: to.trim()
85+
? "default"
86+
: "muted",
87+
},
88+
];
89+
90+
return (
91+
<TabsContent value="claim" className="mt-6">
92+
<div className="grid gap-6 lg:grid-cols-5">
93+
<form onSubmit={onSubmit} className="space-y-5 lg:col-span-3 min-w-0">
94+
<div className="space-y-2 min-w-0">
95+
<Label htmlFor="handle-claim" className="text-[13px] font-medium text-[var(--text-secondary)]">
96+
Handle
97+
</Label>
98+
<Input
99+
id="handle-claim"
100+
placeholder="extencil"
101+
value={handle}
102+
onChange={(e) => onHandleChange(e.target.value)}
103+
autoCapitalize="none"
104+
spellCheck={false}
105+
className="neu-inset"
106+
/>
107+
<p className="text-xs text-[var(--text-muted)]">
108+
Your unique username across all domains. Once claimed, nobody else can use it.
109+
</p>
110+
</div>
111+
112+
<div className="space-y-2">
113+
<Label htmlFor="handle-to" className="text-[13px] font-medium text-[var(--text-secondary)]">
114+
Destination email address
115+
</Label>
116+
<Input
117+
id="handle-to"
118+
type="email"
119+
placeholder="[email protected]"
120+
value={to}
121+
onChange={(e) => onToChange(e.target.value)}
122+
autoCapitalize="none"
123+
spellCheck={false}
124+
className="neu-inset"
125+
/>
126+
<p className="text-xs text-[var(--text-muted)]">Must be a valid mailbox.</p>
127+
</div>
128+
129+
<div className="flex flex-col gap-2 sm:flex-row">
130+
<Button
131+
type="submit"
132+
className="alias-primary neu-btn-green group w-full sm:w-auto"
133+
disabled={requestBusy}
134+
>
135+
{claimButtonContent}
136+
</Button>
137+
</div>
138+
</form>
139+
140+
<div className="hidden space-y-3 lg:col-span-2 lg:block lg:self-start">
141+
<HandleRequestPreviewPanel
142+
intent="subscribe"
143+
state={previewState}
144+
summaryItems={previewSummaryItems}
145+
onViewCurl={onViewCurl}
146+
viewCurlLabel="View cURL command"
147+
/>
148+
</div>
149+
</div>
150+
</TabsContent>
151+
);
152+
}

0 commit comments

Comments
 (0)