Spaces:
Sleeping
Sleeping
| import { useState, useEffect } from 'react' | |
| import { | |
| Link, | |
| Copy, | |
| Check, | |
| AlertTriangle, | |
| Clock, | |
| Globe, | |
| Sparkles, | |
| Shield, | |
| ChevronDown, | |
| ChevronUp, | |
| Loader2, | |
| ExternalLink, | |
| BarChart3, | |
| Eye, | |
| MousePointerClick | |
| } from 'lucide-react' | |
| export default function URLShortener() { | |
| const [url, setUrl] = useState('') | |
| const [customSlug, setCustomSlug] = useState('') | |
| const [expiresAt, setExpiresAt] = useState('') | |
| const [showAdvanced, setShowAdvanced] = useState(false) | |
| const [result, setResult] = useState(null) | |
| const [error, setError] = useState(null) | |
| const [loading, setLoading] = useState(false) | |
| const [copied, setCopied] = useState(false) | |
| const [recentLinks, setRecentLinks] = useState([]) | |
| const [mounted, setMounted] = useState(false) | |
| const [showWarning, setShowWarning] = useState(null) | |
| useEffect(() => { | |
| setMounted(true) | |
| try { | |
| const stored = localStorage.getItem('sh0rtl_recent_links') | |
| if (stored) setRecentLinks(JSON.parse(stored)) | |
| } catch {} | |
| }, []) | |
| useEffect(() => { | |
| if (recentLinks.length > 0) { | |
| try { | |
| localStorage.setItem('sh0rtl_recent_links', JSON.stringify(recentLinks.slice(0, 10))) | |
| } catch {} | |
| } | |
| }, [recentLinks]) | |
| const handleSubmit = async (e) => { | |
| e.preventDefault() | |
| if (!url.trim()) return | |
| setLoading(true) | |
| setError(null) | |
| setResult(null) | |
| setShowWarning(null) | |
| try { | |
| const res = await fetch('/api/shorten', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ url: url.trim(), customSlug: customSlug.trim() || undefined, expiresAt: expiresAt || undefined }) | |
| }) | |
| const data = await res.json() | |
| if (!res.ok) { | |
| setError(data.error || 'Failed to shorten URL') | |
| setLoading(false) | |
| return | |
| } | |
| if (data.warning) { | |
| setShowWarning(data.warning) | |
| } | |
| setResult(data) | |
| setRecentLinks(prev => [data, ...prev]) | |
| setUrl('') | |
| setCustomSlug('') | |
| setExpiresAt('') | |
| } catch (err) { | |
| setError('Network error. Please try again.') | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| const copyToClipboard = async (text) => { | |
| try { | |
| await navigator.clipboard.writeText(text) | |
| setCopied(true) | |
| setTimeout(() => setCopied(false), 2000) | |
| } catch { | |
| setError('Failed to copy to clipboard') | |
| } | |
| } | |
| const getHostname = () => { | |
| if (typeof window !== 'undefined') return window.location.origin | |
| return 'https://sh0rtl.ink' | |
| } | |
| if (!mounted) return null | |
| return ( | |
| <section id="shorten" className="relative py-20"> | |
| <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| <div className="text-center mb-12"> | |
| <h2 className="text-3xl sm:text-4xl font-bold mb-4">Shorten Your URL</h2> | |
| <p className="text-slate-400">Paste a long URL and get a clean, trackable short link in seconds.</p> | |
| </div> | |
| <div className="bg-slate-900/80 backdrop-blur-sm border border-slate-800 rounded-2xl p-6 sm:p-8 shadow-2xl shadow-slate-950/50"> | |
| <form onSubmit={handleSubmit} className="space-y-5"> | |
| <div className="relative"> | |
| <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> | |
| <Link className="w-5 h-5 text-slate-500" /> | |
| </div> | |
| <input | |
| type="url" | |
| value={url} | |
| onChange={(e) => setUrl(e.target.value)} | |
| placeholder="Paste your long URL here..." | |
| required | |
| className="w-full pl-12 pr-4 py-4 bg-slate-950 border border-slate-700 rounded-xl text-slate-100 placeholder-slate-500 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/50 transition-all" | |
| /> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => setShowAdvanced(!showAdvanced)} | |
| className="flex items-center gap-2 text-sm text-slate-400 hover:text-emerald-400 transition-colors" | |
| > | |
| {showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} | |
| Advanced Options | |
| </button> | |
| {showAdvanced && ( | |
| <div className="grid sm:grid-cols-2 gap-4 animate-slide-up"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-400 mb-2"> | |
| Custom Slug <span className="text-slate-600">(optional)</span> | |
| </label> | |
| <div className="relative"> | |
| <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <Sparkles className="w-4 h-4 text-slate-500" /> | |
| </div> | |
| <input | |
| type="text" | |
| value={customSlug} | |
| onChange={(e) => setCustomSlug(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))} | |
| placeholder="my-link" | |
| maxLength={32} | |
| className="w-full pl-10 pr-4 py-3 bg-slate-950 border border-slate-700 rounded-xl text-slate-100 placeholder-slate-600 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/50 transition-all" | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-400 mb-2"> | |
| Expires At <span className="text-slate-600">(optional)</span> | |
| </label> | |
| <div className="relative"> | |
| <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <Clock className="w-4 h-4 text-slate-500" /> | |
| </div> | |
| <input | |
| type="datetime-local" | |
| value={expiresAt} | |
| onChange={(e) => setExpiresAt(e.target.value)} | |
| className="w-full pl-10 pr-4 py-3 bg-slate-950 border border-slate-700 rounded-xl text-slate-100 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/50 transition-all" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <button | |
| type="submit" | |
| disabled={loading || !url.trim()} | |
| className="w-full py-4 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 text-white font-semibold shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/40 disabled:opacity-50 disabled:cursor-not-allowed hover:scale-[1.01] transition-all flex items-center justify-center gap-2" | |
| > | |
| {loading ? ( | |
| <> | |
| <Loader2 className="w-5 h-5 animate-spin" /> | |
| Shortening... | |
| </> | |
| ) : ( | |
| <> | |
| <Sparkles className="w-5 h-5" /> | |
| Shorten URL | |
| </> | |
| )} | |
| </button> | |
| </form> | |
| {showWarning && ( | |
| <div className="mt-5 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-start gap-3 animate-fade-in"> | |
| <AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" /> | |
| <div> | |
| <p className="text-sm font-medium text-amber-400">Security Warning</p> | |
| <p className="text-sm text-amber-300/80 mt-1">{showWarning}</p> | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="mt-5 p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex items-start gap-3 animate-fade-in"> | |
| <AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" /> | |
| <div> | |
| <p className="text-sm font-medium text-red-400">Error</p> | |
| <p className="text-sm text-red-300/80 mt-1">{error}</p> | |
| </div> | |
| </div> | |
| )} | |
| {result && ( | |
| <div className="mt-5 p-5 rounded-xl bg-emerald-500/5 border border-emerald-500/20 animate-fade-in"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <Shield className="w-4 h-4 text-emerald-400" /> | |
| <span className="text-sm font-medium text-emerald-400">URL Shortened Successfully</span> | |
| </div> | |
| <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm text-slate-500 mb-1">Short Link</p> | |
| <a | |
| href={result.shortUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="font-mono text-lg text-emerald-400 hover:text-emerald-300 transition-colors truncate block" | |
| > | |
| {result.shortUrl} | |
| </a> | |
| </div> | |
| <button | |
| onClick={() => copyToClipboard(result.shortUrl)} | |
| className="shrink-0 px-4 py-2.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 transition-all flex items-center justify-center gap-2" | |
| > | |
| {copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />} | |
| {copied ? 'Copied!' : 'Copy'} | |
| </button> | |
| </div> | |
| <div className="mt-3 pt-3 border-t border-slate-800"> | |
| <p className="text-xs text-slate-500 truncate"> | |
| Original: <span className="text-slate-400">{result.originalUrl}</span> | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {recentLinks.length > 0 && ( | |
| <div className="mt-8"> | |
| <h3 className="text-lg font-semibold mb-4 flex items-center gap-2"> | |
| <Clock className="w-5 h-5 text-slate-400" /> | |
| Recent Links | |
| </h3> | |
| <div className="space-y-3"> | |
| {recentLinks.slice(0, 5).map((link, idx) => ( | |
| <div | |
| key={link.slug + idx} | |
| className="bg-slate-900/60 border border-slate-800 rounded-xl p-4 hover:border-slate-700 transition-colors group" | |
| > | |
| <div className="flex flex-col sm:flex-row sm:items-center gap-3"> | |
| <div className="flex-1 min-w-0"> | |
| <a | |
| href={link.shortUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="font-mono text-sm text-emerald-400 hover:text-emerald-300 transition-colors flex items-center gap-1" | |
| > | |
| {link.shortUrl} | |
| <ExternalLink className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" /> | |
| </a> | |
| <p className="text-xs text-slate-500 truncate mt-1">{link.originalUrl}</p> | |
| </div> | |
| <div className="flex items-center gap-4 shrink-0"> | |
| <div className="flex items-center gap-1.5 text-xs text-slate-400"> | |
| <MousePointerClick className="w-3.5 h-3.5" /> | |
| <span>{link.clicks} clicks</span> | |
| </div> | |
| {link.expiresAt && ( | |
| <div className="flex items-center gap-1.5 text-xs text-amber-400"> | |
| <Clock className="w-3.5 h-3.5" /> | |
| <span>Expires</span> | |
| </div> | |
| )} | |
| <button | |
| onClick={() => copyToClipboard(link.shortUrl)} | |
| className="p-1.5 rounded-md hover:bg-slate-800 text-slate-500 hover:text-slate-300 transition-colors" | |
| title="Copy link" | |
| > | |
| <Copy className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </section> | |
| ) | |
| } |