2026-03-02 12:08:48 +01:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useCallback } from 'react';
|
|
|
|
|
|
import { RefreshCw, Copy, Check, Clock } from 'lucide-react';
|
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { cn } from '@/lib/utils/cn';
|
|
|
|
|
|
import { SliderRow } from '@/components/ui/slider-row';
|
|
|
|
|
|
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
|
|
|
|
|
import {
|
|
|
|
|
|
generatePassword, passwordEntropy,
|
|
|
|
|
|
generateUUID,
|
|
|
|
|
|
generateApiKey,
|
|
|
|
|
|
generateHash,
|
|
|
|
|
|
generateToken,
|
|
|
|
|
|
type PasswordOpts,
|
|
|
|
|
|
type ApiKeyOpts,
|
|
|
|
|
|
type HashOpts,
|
|
|
|
|
|
type TokenOpts,
|
|
|
|
|
|
} from '@/lib/random/generators';
|
|
|
|
|
|
|
|
|
|
|
|
type GeneratorType = 'password' | 'uuid' | 'apikey' | 'hash' | 'token';
|
|
|
|
|
|
type MobileTab = 'configure' | 'output';
|
|
|
|
|
|
|
|
|
|
|
|
const GENERATOR_TABS: { value: GeneratorType; label: string }[] = [
|
|
|
|
|
|
{ value: 'password', label: 'Password' },
|
|
|
|
|
|
{ value: 'uuid', label: 'UUID' },
|
|
|
|
|
|
{ value: 'apikey', label: 'API Key' },
|
|
|
|
|
|
{ value: 'hash', label: 'Hash' },
|
|
|
|
|
|
{ value: 'token', label: 'Token' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const selectCls =
|
|
|
|
|
|
'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer';
|
|
|
|
|
|
|
|
|
|
|
|
const strengthLabel = (bits: number) => {
|
|
|
|
|
|
if (bits < 40) return { label: 'Weak', color: 'bg-red-500' };
|
|
|
|
|
|
if (bits < 60) return { label: 'Fair', color: 'bg-amber-400' };
|
|
|
|
|
|
if (bits < 80) return { label: 'Good', color: 'bg-yellow-400' };
|
|
|
|
|
|
if (bits < 100) return { label: 'Strong', color: 'bg-emerald-400' };
|
|
|
|
|
|
return { label: 'Very Strong', color: 'bg-primary' };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function RandomGenerator() {
|
|
|
|
|
|
const [type, setType] = useState<GeneratorType>('password');
|
|
|
|
|
|
const [mobileTab, setMobileTab] = useState<MobileTab>('configure');
|
|
|
|
|
|
const [output, setOutput] = useState('');
|
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
const [generating, setGenerating] = useState(false);
|
|
|
|
|
|
const [history, setHistory] = useState<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
// Options per type
|
|
|
|
|
|
const [pwOpts, setPwOpts] = useState<PasswordOpts>({
|
|
|
|
|
|
length: 24, uppercase: true, lowercase: true, numbers: true, symbols: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
const [apiOpts, setApiOpts] = useState<ApiKeyOpts>({
|
|
|
|
|
|
length: 32, format: 'hex', prefix: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
const [hashOpts, setHashOpts] = useState<HashOpts>({
|
|
|
|
|
|
algorithm: 'SHA-256', input: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
const [tokenOpts, setTokenOpts] = useState<TokenOpts>({
|
|
|
|
|
|
bytes: 32, format: 'hex',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const pushHistory = (val: string) =>
|
|
|
|
|
|
setHistory((h) => [val, ...h].slice(0, 8));
|
|
|
|
|
|
|
|
|
|
|
|
const generate = useCallback(async () => {
|
|
|
|
|
|
setGenerating(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
let result = '';
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'password': result = generatePassword(pwOpts); break;
|
|
|
|
|
|
case 'uuid': result = generateUUID(); break;
|
|
|
|
|
|
case 'apikey': result = generateApiKey(apiOpts); break;
|
|
|
|
|
|
case 'hash': result = await generateHash(hashOpts); break;
|
|
|
|
|
|
case 'token': result = generateToken(tokenOpts); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
setOutput(result);
|
|
|
|
|
|
pushHistory(result);
|
|
|
|
|
|
setMobileTab('output');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
toast.error('Generation failed');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setGenerating(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [type, pwOpts, apiOpts, hashOpts, tokenOpts]);
|
|
|
|
|
|
|
|
|
|
|
|
const copy = (val = output) => {
|
|
|
|
|
|
if (!val) return;
|
|
|
|
|
|
navigator.clipboard.writeText(val);
|
|
|
|
|
|
setCopied(true);
|
|
|
|
|
|
toast.success('Copied to clipboard');
|
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const entropy = type === 'password' ? passwordEntropy(pwOpts) : null;
|
|
|
|
|
|
const strength = entropy !== null ? strengthLabel(entropy) : null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
<MobileTabs
|
|
|
|
|
|
tabs={[{ value: 'configure', label: 'Configure' }, { value: 'output', label: 'Output' }]}
|
|
|
|
|
|
active={mobileTab}
|
|
|
|
|
|
onChange={(v) => setMobileTab(v as MobileTab)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
|
|
|
|
|
style={{ height: 'calc(100svh - 120px)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* ── Left: type selector + options ───────────────────── */}
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
|
|
|
|
|
|
mobileTab !== 'configure' && 'hidden lg:flex'
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{/* Type selector */}
|
|
|
|
|
|
<div className="glass rounded-xl p-4 shrink-0">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
|
|
|
|
|
|
Generator
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
{GENERATOR_TABS.map(({ value, label }) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={value}
|
|
|
|
|
|
onClick={() => { setType(value); setOutput(''); }}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'w-full text-left px-3 py-2 rounded-lg text-xs font-mono transition-all',
|
|
|
|
|
|
type === value
|
|
|
|
|
|
? 'bg-primary/15 border border-primary/30 text-primary'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.03] border border-transparent'
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Options */}
|
|
|
|
|
|
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-4 shrink-0">
|
|
|
|
|
|
Options
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Password ── */}
|
|
|
|
|
|
{type === 'password' && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<SliderRow
|
|
|
|
|
|
label="Length"
|
|
|
|
|
|
display={`${pwOpts.length} chars`}
|
|
|
|
|
|
value={pwOpts.length}
|
|
|
|
|
|
min={4} max={128}
|
|
|
|
|
|
onChange={(v) => setPwOpts((o) => ({ ...o, length: v }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Character sets
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
{([
|
|
|
|
|
|
{ key: 'uppercase', label: 'A–Z', hint: 'Uppercase' },
|
|
|
|
|
|
{ key: 'lowercase', label: 'a–z', hint: 'Lowercase' },
|
|
|
|
|
|
{ key: 'numbers', label: '0–9', hint: 'Numbers' },
|
|
|
|
|
|
{ key: 'symbols', label: '!@#', hint: 'Symbols' },
|
|
|
|
|
|
] as const).map(({ key, label, hint }) => (
|
|
|
|
|
|
<label
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-all select-none',
|
|
|
|
|
|
pwOpts[key]
|
|
|
|
|
|
? 'bg-primary/10 border-primary/30 text-primary'
|
|
|
|
|
|
: 'border-border/30 text-muted-foreground/50 hover:border-border/50 hover:text-muted-foreground'
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={hint}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={pwOpts[key]}
|
|
|
|
|
|
onChange={(e) => setPwOpts((o) => ({ ...o, [key]: e.target.checked }))}
|
|
|
|
|
|
className="sr-only"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs font-mono">{label}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{strength && (
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Strength
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-[10px] font-mono text-muted-foreground/40">
|
|
|
|
|
|
{entropy} bits
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="h-1 rounded-full bg-white/[0.06] overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn('h-full rounded-full transition-all duration-500', strength.color)}
|
|
|
|
|
|
style={{ width: `${Math.min(100, (entropy! / 128) * 100)}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className={cn('text-[10px] font-mono', strength.color.replace('bg-', 'text-'))}>
|
|
|
|
|
|
{strength.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── UUID ── */}
|
|
|
|
|
|
{type === 'uuid' && (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="px-3 py-2.5 rounded-lg bg-white/[0.02] border border-border/20">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground/60 leading-relaxed">
|
|
|
|
|
|
Generates a cryptographically random UUID v4 using the browser's built-in{' '}
|
|
|
|
|
|
<code className="text-primary/70 text-[10px]">crypto.randomUUID()</code>.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-[10px] font-mono text-muted-foreground/30">
|
|
|
|
|
|
Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── API Key ── */}
|
|
|
|
|
|
{type === 'apikey' && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<SliderRow
|
|
|
|
|
|
label="Length"
|
|
|
|
|
|
display={`${apiOpts.length} chars`}
|
|
|
|
|
|
value={apiOpts.length}
|
|
|
|
|
|
min={8} max={64}
|
|
|
|
|
|
onChange={(v) => setApiOpts((o) => ({ ...o, length: v }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Encoding
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={apiOpts.format}
|
|
|
|
|
|
onChange={(e) => setApiOpts((o) => ({ ...o, format: e.target.value as ApiKeyOpts['format'] }))}
|
|
|
|
|
|
className={selectCls}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="hex">Hex (0-9, a-f)</option>
|
|
|
|
|
|
<option value="base62">Base62 (alphanumeric)</option>
|
|
|
|
|
|
<option value="base64url">Base64url</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Prefix <span className="normal-case font-normal text-muted-foreground/40">(optional)</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={apiOpts.prefix}
|
|
|
|
|
|
onChange={(e) => setApiOpts((o) => ({ ...o, prefix: e.target.value }))}
|
|
|
|
|
|
placeholder="sk, pk, api..."
|
|
|
|
|
|
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/25"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Hash ── */}
|
|
|
|
|
|
{type === 'hash' && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Algorithm
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={hashOpts.algorithm}
|
|
|
|
|
|
onChange={(e) => setHashOpts((o) => ({ ...o, algorithm: e.target.value as HashOpts['algorithm'] }))}
|
|
|
|
|
|
className={selectCls}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="SHA-1">SHA-1 (160 bit)</option>
|
|
|
|
|
|
<option value="SHA-256">SHA-256 (256 bit)</option>
|
|
|
|
|
|
<option value="SHA-512">SHA-512 (512 bit)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Input <span className="normal-case font-normal text-muted-foreground/40">(empty = random)</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={hashOpts.input}
|
|
|
|
|
|
onChange={(e) => setHashOpts((o) => ({ ...o, input: e.target.value }))}
|
|
|
|
|
|
placeholder="Text to hash, or leave empty for random data..."
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/25 resize-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Token ── */}
|
|
|
|
|
|
{type === 'token' && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Byte length
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
|
|
|
|
{[16, 32, 48, 64].map((b) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={b}
|
|
|
|
|
|
onClick={() => setTokenOpts((o) => ({ ...o, bytes: b }))}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'py-1.5 rounded-lg text-xs font-mono border transition-all',
|
|
|
|
|
|
tokenOpts.bytes === b
|
|
|
|
|
|
? 'bg-primary/15 border-primary/30 text-primary'
|
|
|
|
|
|
: 'border-border/30 text-muted-foreground/50 hover:border-border/50 hover:text-muted-foreground'
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{b}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-[10px] font-mono text-muted-foreground/30">
|
|
|
|
|
|
{tokenOpts.bytes * 8} bits of entropy
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Encoding
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={tokenOpts.format}
|
|
|
|
|
|
onChange={(e) => setTokenOpts((o) => ({ ...o, format: e.target.value as TokenOpts['format'] }))}
|
|
|
|
|
|
className={selectCls}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="hex">Hex</option>
|
|
|
|
|
|
<option value="base64url">Base64url</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Right: output + history ──────────────────────────── */}
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'lg:col-span-3 flex flex-col gap-3 overflow-hidden',
|
|
|
|
|
|
mobileTab !== 'output' && 'hidden lg:flex'
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{/* Output display */}
|
|
|
|
|
|
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-3 shrink-0">
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Output
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{output && (
|
|
|
|
|
|
<span className="text-[9px] font-mono text-muted-foreground/30 tabular-nums">
|
|
|
|
|
|
{output.length} chars
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Value box */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="relative flex-1 min-h-0 rounded-xl overflow-hidden border border-white/[0.06]"
|
|
|
|
|
|
style={{ background: '#06060e' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{output ? (
|
|
|
|
|
|
<div className="absolute inset-0 p-5 overflow-auto scrollbar-thin scrollbar-thumb-white/10">
|
|
|
|
|
|
<p className="font-mono text-sm text-white/80 break-all leading-relaxed select-all">
|
|
|
|
|
|
{output}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
|
|
|
|
<p className="text-xs font-mono text-white/15 italic">
|
|
|
|
|
|
Press Generate to create a value
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
|
<div className="flex gap-2 mt-3 shrink-0">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={generate}
|
|
|
|
|
|
disabled={generating}
|
2026-03-02 15:42:47 +01:00
|
|
|
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-primary/30 bg-primary/[0.08] hover:border-primary/55 hover:bg-primary/[0.15] text-xs font-medium text-primary transition-all duration-200 disabled:opacity-50"
|
2026-03-02 12:08:48 +01:00
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw className={cn('w-3.5 h-3.5', generating && 'animate-spin')} />
|
|
|
|
|
|
Generate
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => copy()}
|
|
|
|
|
|
disabled={!output}
|
2026-03-02 15:42:47 +01:00
|
|
|
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg glass border border-border/30 text-xs font-mono text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-30"
|
2026-03-02 12:08:48 +01:00
|
|
|
|
>
|
|
|
|
|
|
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
|
|
|
|
|
{copied ? 'Copied' : 'Copy'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* History */}
|
|
|
|
|
|
{history.length > 0 && (
|
|
|
|
|
|
<div className="glass rounded-xl p-4 shrink-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-3">
|
|
|
|
|
|
<Clock className="w-3 h-3 text-muted-foreground/40" />
|
|
|
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
|
Recent
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{history.map((item, i) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
className="group flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.02] transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-[10px] font-mono text-white/30 group-hover:text-white/50 transition-colors truncate flex-1">
|
|
|
|
|
|
{item}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => copy(item)}
|
|
|
|
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground/40 hover:text-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy className="w-3 h-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|