Feat: Cloudflare-like landing page w/ Lucide

This commit is contained in:
2025-09-26 19:32:43 -07:00
parent 9bc29dcc8a
commit 3d4bd0879c

View File

@@ -1,171 +1,110 @@
<!doctype html><html lang=en>
<head>
<meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>4ev.link — Short, simple, 4ever</title>
<meta name=description content="Free, fast URL shortener. No account needed. Random 4-char slugs now. Custom slugs coming soon.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<title>4ev.link — Short links. Forever.</title>
<meta name=description content="Create simple, fast, permanent short links on 4ev.link.">
<meta name=color-scheme content="light dark">
<meta property=og:title content="4ev.link — Short links. Forever.">
<meta property=og:description content="Create simple, fast, permanent short links on 4ev.link.">
<meta property=og:type content=website><meta property=og:url content="https://4ev.link/">
<link rel=icon href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 24 24'%3E%3Cpath fill='%23ff6a00' d='M10.59 13.41a1.996 1.996 0 0 1 0-2.82l2-2a2 2 0 1 1 2.82 2.83l-1 1 1.41 1.41 1-1a4 4 0 1 0-5.66-5.65l-2 2a4 4 0 0 0 0 5.65l.43.43 1.41-1.41-.41-.43Zm2.82-2.82a2 2 0 0 1 0 2.83l-2 2a2 2 0 1 1-2.82-2.83l1-1-1.41-1.41-1 1a4 4 0 1 0 5.66 5.65l2-2a4 4 0 0 0 0-5.65l-.43-.43-1.41 1.41.41.43Z'/%3E%3C/svg%3E">
<style>
:root{--bg:#fff;--fg:#111;--muted:#6b7280;--b:#e5e7eb;--b2:#d1d5db;--card:#fff;--shadow:0 0 0 1px rgba(0,0,0,.04),0 6px 20px rgba(0,0,0,.06);--acc:#111;--ok:#16a34a;--bad:#ef4444}
*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 Inter,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
.container{max-width:1100px;margin:auto;padding:16px}
.nav{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 0}
.brand{display:flex;align-items:center;gap:10px;font:700 18px/1 Space Grotesk,Inter;color:var(--fg)}
.brand i{width:20px;height:20px}
.actions{display:flex;gap:8px}
.btn{appearance:none;border:1px solid var(--b);background:#fff;color:#111;border-radius:8px;padding:10px 14px;cursor:pointer;font-weight:600}
.btn:hover{border-color:var(--b2)}
.btn.primary{background:#111;color:#fff;border-color:#111}
:root{--bg:#0b0c0f;--card:#0f1116;--muted:#8a93a6;--fg:#e6e7eb;--brand:#ff6a00;--brand2:#ffb35c;--ok:#22c55e;--err:#ef4444;--ring:0 0 0 2px color-mix(in oklab,var(--brand) 40%,transparent)}
:root[data-t=light]{--bg:#ffffff;--card:#f6f7fb;--muted:#5b6476;--fg:#0b0c0f}
*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:500 16px ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Inter,Arial,Apple Color Emoji,Segoe UI Emoji;background:radial-gradient(1200px 600px at 80% -10%,#ff6a0015,transparent 60%),radial-gradient(900px 500px at -10% 10%,#4f46e515,transparent 60%),var(--bg);color:var(--fg)}
a{color:inherit;text-decoration:none}button,input{font:inherit}
.container{max-width:1100px;margin:auto;padding:24px}
.nav{display:flex;gap:16px;align-items:center;justify-content:space-between}
.logo{display:flex;gap:10px;align-items:center}
.badge{font:700 10px/1.6 ui-monospace,monospace;background:#ffffff12;color:#fff;border:1px solid #ffffff22;padding:2px 6px;border-radius:999px;letter-spacing:.06em;text-transform:uppercase}
.nav a{opacity:.8} .nav a:hover{opacity:1}
.btn{display:inline-flex;gap:8px;align-items:center;justify-content:center;border:1px solid #ffffff22;background:linear-gradient(180deg,#ffffff10,#00000000);color:var(--fg);border-radius:10px;padding:10px 14px;cursor:pointer;transition:transform .05s ease,opacity .2s ease;will-change:transform,opacity}
.btn:active{transform:translateY(1px)} .btn[disabled]{opacity:.6;cursor:not-allowed}
.btn.brand{background:linear-gradient(180deg,color-mix(in oklab,var(--brand) 20%,transparent),transparent);border-color:color-mix(in oklab,var(--brand) 40%,transparent)}
.btn.ghost{background:transparent}
.hero{padding:32px 0 18px;text-align:center}
h1{margin:0 0 8px;font:700 clamp(28px,6vw,40px)/1.15 Space Grotesk,Inter}
h1 .heart{font-size:1em;color:#e11d48}
.sub{color:var(--muted);font-weight:600}
.card{background:var(--card);border:1px solid var(--b);border-radius:14px;box-shadow:var(--shadow)}
.form{margin:18px auto 0;max-width:780px;padding:14px}
.row{display:flex;gap:8px;align-items:center}
.row>.grow{flex:1}
.input{width:100%;border:1px solid var(--b);border-radius:10px;padding:12px 14px;font-size:15px}
.input:focus{outline:none;border-color:#9ca3af;box-shadow:0 0 0 3px rgba(0,0,0,.06)}
.help{margin-top:8px;color:var(--muted);font-size:13px}
.result{margin-top:12px;padding:12px;border:1px dashed var(--b);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:8px}
.result .link{font:600 15px/1.4 "JetBrains Mono",monospace;word-break:break-all}
.badge{display:inline-flex;gap:6px;align-items:center;border:1px solid var(--b);padding:6px 10px;border-radius:999px;font-size:12px;color:#111;background:#fff}
.grid{display:grid;gap:12px;margin:24px 0}
@media(min-width:760px){.grid{grid-template-columns:repeat(3,1fr)}}
.feature{padding:16px}
.feature i{width:18px;height:18px}
small.muted{color:var(--muted)}
.footer{margin:26px 0;color:var(--muted);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
.modal{position:fixed;inset:0;display:grid;place-items:center;background:rgba(17,17,17,.42);padding:16px}
.sheet{max-width:420px;width:100%}
.close{position:absolute;top:10px;right:10px;background:#fff;border:1px solid var(--b);border-radius:8px;padding:6px;cursor:pointer}
.disabled{opacity:.45;pointer-events:none}
.kbd{font:600 12px/1 "JetBrains Mono",monospace;background:#f5f5f5;border:1px solid var(--b);border-bottom-width:2px;border-radius:6px;padding:2px 6px}
.spinner{width:18px;height:18px;border:2px solid #fff;border-right-color:transparent;border-radius:50%;display:inline-block;animation:s .6s linear infinite;vertical-align:middle}
@keyframes s{to{transform:rotate(1turn)}}
.note{background:#fafafa;border:1px solid var(--b);border-radius:12px;padding:10px;font-size:13px}
.hero{padding:60px 0 36px;text-align:center}
.h1{font-size:44px;line-height:1.08;letter-spacing:-.02em;margin:0 0 12px}
.p{margin:0 auto;color:var(--muted);max-width:740px}
.card{background:linear-gradient(180deg,#ffffff08,#0000),var(--card);border:1px solid #ffffff12;border-radius:14px;box-shadow:0 8px 40px #00000020}
.form{margin:28px auto 0;max-width:760px;display:flex;gap:10px;padding:14px}
.field{flex:1;display:flex;gap:10px;align-items:center;background:#00000025;border:1px solid #ffffff12;border-radius:10px;padding:10px 12px}
input.url{all:unset;color:var(--fg);width:100%} input.url::placeholder{color:#9aa3b2}
.msg{margin:10px auto 0;max-width:760px;color:var(--muted)}
.msg.err{color:var(--err)} .msg.ok{color:var(--ok)}
.result{margin:14px auto 0;max-width:760px;padding:12px;display:none;align-items:center;gap:10px}
.result.show{display:flex}
.code{flex:1;background:#00000025;border:1px dashed #ffffff22;border-radius:10px;padding:12px;font:600 14px ui-monospace,monospace;overflow:auto;white-space:nowrap}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin:36px 0 10px}
.card.i{padding:16px}
.card h3{margin:8px 0 4px;font-size:16px}
.card p{margin:0;color:var(--muted);font-weight:450}
footer{padding:30px 0 60px;color:var(--muted);text-align:center}
.kbd{font:600 12px ui-monospace,monospace;background:#ffffff10;padding:2px 6px;border-radius:6px;border:1px solid #ffffff22}
:focus-visible{outline:none;box-shadow:var(--ring);border-radius:10px}
@media (max-width:640px){.h1{font-size:34px}.form{flex-direction:column}.result{flex-direction:column;align-items:stretch}}
</style>
</head>
<body x-data="app()" x-init="init()">
<div class=container>
<nav class=nav>
<a class=brand href="/">
<i data-lucide="link-2"></i><span>4ev.link</span>
</a>
<div class=actions>
<button class="btn ghost" @click="openAuth('login')"><i data-lucide="log-in"></i></button>
<button class="btn" @click="openAuth('signup')">Sign up</button>
</div>
</nav>
<header class=hero>
<h1>Get ur 4ev.link <span class=heart>❤️</span></h1>
<div class=sub>Free. No account needed. Cloudflare-fast.</div>
</header>
<section class="card form" @keydown.enter.prevent="shorten()">
<div class=row>
<div class=grow><input class=input type=url x-model.trim="url" placeholder="Paste a long URL, e.g. https://example.com/some/page?ref=4ev" aria-label="Long URL"></div>
<button class="btn primary" :disabled="busy||!url" @click="shorten()">
<span x-show="!busy"><i data-lucide="wand-2"></i> Shorten</span>
<span x-show="busy"><span class=spinner></span> Working…</span>
</button>
</div>
<div class=help>
• Free plan: random 4-char slugs, e.g. <span class=kbd>/aZ3b</span> • Paid: custom slugs <span class=badge><i data-lucide="clock-3"></i> coming soon</span>
</div>
<template x-if="err">
<div class="note" style="color:var(--bad);margin-top:10px"><i data-lucide="alert-triangle"></i> <span x-text="err"></span></div>
</template>
<template x-if="res">
<div class=result>
<div class=link>
<div>Your 4ev.link</div>
<a :href="res.shortUrl" target=_blank rel=noopener x-text="res.shortUrl"></a>
</div>
<div style="display:flex;gap:8px">
<button class=btn @click="copy(res.shortUrl)"><i data-lucide="copy"></i><span x-text="copied?'Copied!':'Copy'"></span></button>
<a class="btn" :href="res.shortUrl" target=_blank rel=noopener><i data-lucide="external-link"></i> Open</a>
</div>
</div>
</template>
<details style="margin-top:12px">
<summary class="badge"><i data-lucide="sparkles"></i> Custom URL (paid, soon)</summary>
<div class="row" style="margin-top:10px">
<span class="badge disabled"><i data-lucide="lock"></i> yourbrand</span>
<input class="input disabled" disabled placeholder="Choose your slug (paid soon)">
<button class="btn disabled"><i data-lucide="check"></i> Reserve</button>
</div>
<div class="help">Want this sooner? <a href="#" @click.prevent="openAuth('signup')">Sign up</a> to get notified.</div>
</details>
</section>
<section class="grid">
<article class="card feature">
<div class="badge"><i data-lucide="zap"></i> Fast redirects</div>
<p>Backed by Cloudflare Workers + KV, your links resolve globally with 302 redirects.</p>
</article>
<article class="card feature">
<div class="badge"><i data-lucide="shield"></i> Private by default</div>
<p>No account required to shorten. We only store your long URL and a short code.</p>
</article>
<article class="card feature">
<div class="badge"><i data-lucide="terminal"></i> Simple API</div>
<p>POST / with {"url": "..."} → {"shortUrl": "..."} — exactly what this page uses.</p>
</article>
</section>
<section class="card feature">
<div class="badge"><i data-lucide="info"></i> How it works</div>
<ol style="margin:10px 0 0;padding-left:18px">
<li>Paste your long URL.</li>
<li>We validate and create a random 4-char slug.</li>
<li>Share your 4ev.link — visitors get a 302 redirect to your target.</li>
</ol>
</section>
<footer class=footer>
<div>© <span x-text="year"></span> 4ev.link · <a href="#" @click.prevent="openAuth('login')">Log in</a></div>
<div><small class=muted>Made with <span class=heart>❤️</span> · Inspired by Cloudflare · Fonts: Inter + Space Grotesk + JetBrains Mono</small></div>
</footer>
</div>
<template x-if="auth.open">
<div class=modal @click.self="auth.open=false" x-trap.inert.noscroll="auth.open">
<div class="sheet card" style="position:relative;padding:16px">
<button class=close @click="auth.open=false"><i data-lucide="x"></i></button>
<h3 style="margin:0 0 8px;font:700 20px/1.2 Space Grotesk">[[ <span x-text="auth.mode==='login'?'Login':'Sign up'"></span> ]]</h3>
<p class=help x-show="auth.mode==='signup'">Custom URLs and teams coming soon — join the waitlist.</p>
<div class="grid" style="grid-template-columns:1fr">
<input class=input type=email x-model.trim="auth.email" placeholder="you@example.com" autocomplete="email">
<button class="btn primary" @click="notify()"><i data-lucide="mail"></i> <span x-text="auth.mode==='login'?'Send magic link':'Notify me'"></span></button>
</div>
<p class="help" style="margin-top:8px">Well email you when paid plans launch. No spam, unsubscribe anytime.</p>
</div>
<body>
<div class=container>
<nav class=nav>
<div class=logo>
<i data-lucide=link stroke-width=2.6 style="color:var(--brand)"></i>
<strong>4ev.link</strong>
<span class=badge>beta</span>
</div>
</template>
<div style="display:flex;gap:8px;align-items:center">
<a class=btn ghost href="https://github.com/4ev-link/4ev.link" target=_blank rel=noopener><i data-lucide=github></i><span>GitHub</span></a>
<button id=theme class="btn ghost" aria-label="Toggle theme"><i data-lucide=sun></i></button>
</div>
</nav>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="https://unpkg.com/lucide@latest" defer></script>
<script>
const app=()=>({year:new Date().getFullYear(),url:'',res:null,err:'',busy:!1,copied:!1,auth:{open:!1,mode:'login',email:''},
init(){addEventListener('load',()=>window.lucide&&lucide.createIcons())},
openAuth(m){this.auth={open:!0,mode:m,email:''};setTimeout(()=>window.lucide&&lucide.createIcons(),0)},
norm(u){return/^[a-z][a-z0-9+.-]*:\/\//i.test(u)?u:'https://'+u},
async shorten(){this.err='';this.res=null;if(!this.url)return;
try{this.busy=!0;const r=await fetch('/',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.norm(this.url)})});
if(!r.ok)throw new Error((await r.text())||'Failed to shorten');this.res=await r.json();this.$nextTick(()=>window.lucide&&lucide.createIcons())
}catch(e){this.err=e.message||'Something went wrong'}finally{this.busy=!1}
},
async copy(t){try{await navigator.clipboard.writeText(t);this.copied=!0;setTimeout(()=>this.copied=!1,1200)}catch(e){this.err='Copy failed'}}
,notify(){this.auth.open=!1;alert('Thanks! Well keep you posted.')}
});
</script>
<header class=hero>
<h1 class=h1>Short links. Forever.</h1>
<p class=p>Generate simple, human-friendly slugs backed by an edge KV. No accounts. No bloat. Built to last.</p>
<form id=f class="card form" autocomplete=off>
<div class=field>
<i data-lucide=globe stroke-width=2></i>
<input class=url id=url name=url type=url placeholder="https://example.com/very/long/url" inputmode=url spellcheck=false required>
</div>
<button id=go class="btn brand" type=submit><i data-lucide=bolt></i><span>Shorten</span></button>
</form>
<div id=msg class=msg role=status aria-live=polite></div>
<div id=res class="card result" aria-live=polite>
<div class=code id=out title="Click to copy"></div>
<button id=copy class=btn><i data-lucide=copy></i><span>Copy</span></button>
<a id=open class="btn ghost" target=_blank rel=noopener><i data-lucide=external-link></i><span>Open</span></a>
</div>
</header>
<section class=grid aria-label="Highlights">
<article class="card i"><i data-lucide=zap></i><h3>Edge-fast</h3><p>Served from the edge with 302 redirects.</p></article>
<article class="card i"><i data-lucide=shield-check></i><h3>Safe by default</h3><p>Strict URL validation, CORS-ready API.</p></article>
<article class="card i"><i data-lucide=infinity></i><h3>Forever</h3><p>KV-backed slugs built to outlast tabs.</p></article>
<article class="card i"><i data-lucide=wand-2></i><h3>Nice slugs</h3><p>Clean, 4-char defaults; customize later.</p></article>
<article class="card i"><i data-lucide=palette></i><h3>Light/Dark</h3><p>Auto theme with manual toggle.</p></article>
<article class="card i"><i data-lucide=keyboard></i><h3>Keyboard-first</h3><p><span class=kbd>Enter</span> to shorten, <span class=kbd>⌘/Ctrl+C</span> to copy.</p></article>
</section>
<footer>© <span id=year></span> 4ev.link • Built on Cloudflare • Icons by Lucide</footer>
</div>
<script src="https://unpkg.com/lucide@0.462.0/dist/umd/lucide.min.js" defer></script>
<script>
(()=>{const d=document,e=(q,o=d)=>o.querySelector(q),A=(q,o=d)=>o.querySelectorAll(q),S=(k,v)=>localStorage.setItem(k,v),G=k=>localStorage.getItem(k),root=d.documentElement,yr=e('#year'),f=e('#f'),url=e('#url'),go=e('#go'),msg=e('#msg'),res=e('#res'),out=e('#out'),cpy=e('#copy'),op=e('#open'),th=e('#theme');yr.textContent=new Date().getFullYear();
const applyTheme=t=>{root.dataset.t=t;th.innerHTML='';th.insertAdjacentHTML('beforeend',`<i data-lucide='${t==='light'?'moon':'sun'}'></i>`);lucide&&lucide.createIcons()}
const initTheme=()=>{const pref=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';applyTheme(G('t')||pref)}
th.onclick=()=>{const t=root.dataset.t==='dark'?'light':'dark';applyTheme(t);S('t',t)}
const setMsg=(m,c='')=>{msg.className=`msg ${c}`;msg.textContent=m||''}
const showRes=u=>{out.textContent=u;op.href=u;res.classList.add('show')}
const clearRes=()=>{res.classList.remove('show');out.textContent='';op.removeAttribute('href')}
const valid=v=>{try{return new URL(v),true}catch{return false}}
f.onsubmit=async ev=>{ev.preventDefault();setMsg('');clearRes();let v=url.value.trim();if(!valid(v))return setMsg('Please enter a valid URL', 'err');go.disabled=true;go.style.opacity=.7;
try{const r=await fetch('/',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:v})});if(!r.ok){const t=await r.text();throw new Error(t||'Failed')}const j=await r.json();showRes(j.shortUrl);setMsg('Link created!', 'ok')}catch(err){setMsg(err.message||'Something went wrong','err')}finally{go.disabled=false;go.style.opacity=1}}
cpy.onclick=async()=>{let t=out.textContent;if(!t)return;try{await navigator.clipboard.writeText(t);cpy.innerHTML='<i data-lucide=check></i><span>Copied</span>';lucide.createIcons();setTimeout(()=>{cpy.innerHTML='<i data-lucide=copy></i><span>Copy</span>';lucide.createIcons()},1200)}catch{}}
out.onclick=()=>cpy.click()
d.addEventListener('keydown',ev=>{if(ev.key==='Enter'&&d.activeElement===d.body)url.focus()})
d.addEventListener('DOMContentLoaded',()=>{lucide&&lucide.createIcons();initTheme();url.focus()})
})();
</script>
</body>
</html>