mirror of
https://github.com/multipleof4/4ev.link.git
synced 2026-01-13 15:57:53 +00:00
Feat: Mobile-first landing page w/Alpine & API
This commit is contained in:
293
index.html
293
index.html
@@ -1,171 +1,168 @@
|
||||
<!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">
|
||||
<meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>4ev.link — simple, fast URL shortener</title>
|
||||
<meta name=description content="Get your 4ev.link in seconds. Free, no account needed. Pro custom URLs coming soon.">
|
||||
<meta property=og:title content="4ev.link — simple, fast URL shortener">
|
||||
<meta property=og:description content="Free works. No account needed. Pro custom slugs (coming soon).">
|
||||
<meta property=og:type content=website><meta property=og:url content="https://4ev.link/">
|
||||
<meta name=color-scheme content=light only>
|
||||
<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;500;600&family=Poppins:wght@600;700&family=JetBrains+Mono:wght@500&display=swap" rel=stylesheet>
|
||||
<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)}
|
||||
:root{--bg:#fff;--fg:#111;--mut:#6b7280;--card:#f7f7f7;--bor:#e5e7eb;--ink:#111;--acc:#111;--heart:#e11d48}
|
||||
*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:16px/1.4 Inter,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial,sans-serif;background:#fff;color:var(--fg);-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--ink);text-decoration:none}a:hover{text-decoration:underline}
|
||||
.container{max-width:960px;margin:0 auto;padding:20px}
|
||||
.header{display:flex;align-items:center;justify-content:space-between;padding:8px 0}
|
||||
.brand{display:flex;gap:10px;align-items:center;font-weight:700}
|
||||
.brand i{width:22px;height:22px}
|
||||
.brand span{font-family:Poppins,Inter,sans-serif;letter-spacing:.2px}
|
||||
.nav{display:flex;gap:8px;align-items:center}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border:1px solid var(--bor);border-radius:10px;background:#fff;color:#111;font-weight:600;cursor:pointer;transition:.15s}
|
||||
.btn:hover{transform:translateY(-1px)}
|
||||
.btn.primary{background:#111;color:#fff;border-color:#111}
|
||||
.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}
|
||||
.btn.ghost{background:#fff}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
||||
.hero{padding:36px 0 10px;text-align:center}
|
||||
.hero h1{margin:0 auto 8px;font:700 clamp(24px,5.5vw,44px)/1.1 Poppins,Inter,sans-serif;letter-spacing:.2px}
|
||||
.sub{color:var(--mut);font-weight:500}
|
||||
.panel{margin:18px auto;padding:16px;border:1px solid var(--bor);border-radius:14px;background:var(--card)}
|
||||
.form{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center}
|
||||
@media (max-width:640px){.form{grid-template-columns:1fr}.brand span{font-size:16px}.brand i{width:20px;height:20px}}
|
||||
.input{width:100%;padding:12px 14px;border:1px solid var(--bor);border-radius:10px;background:#fff;outline:none}
|
||||
.input:focus{border-color:#111;box-shadow:0 0 0 3px rgba(17,17,17,.08)}
|
||||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.meta{font-size:13px;color:var(--mut)}
|
||||
.kbd{font:500 13px JetBrains Mono,monospace;background:#fff;border:1px solid var(--bor);border-radius:6px;padding:2px 6px}
|
||||
.hr{height:1px;background:var(--bor);margin:18px 0;border:0}
|
||||
.card{background:#fff;border:1px solid var(--bor);border-radius:14px;padding:16px}
|
||||
.grid{display:grid;gap:10px}
|
||||
@media(min-width:720px){.grid.cols-2{grid-template-columns:1fr 1fr}.grid.cols-3{grid-template-columns:repeat(3,1fr)}}
|
||||
.mono{font:600 14px/1.3 JetBrains Mono,monospace}
|
||||
.badge{display:inline-flex;gap:6px;align-items:center;font:600 12px/1 Inter;border:1px solid var(--bor);border-radius:999px;padding:6px 10px;background:#fff;color:#111}
|
||||
.footer{padding:24px 0 40px;color:var(--mut);text-align:center}
|
||||
.note{font-size:13px;color:var(--mut)}
|
||||
.copy{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
h2{margin:0 0 8px;font:700 18px/1.2 Poppins,Inter,sans-serif}
|
||||
ul{margin:0;padding-left:18px}li{margin:6px 0}
|
||||
.small{font-size:12px;color:var(--mut)}
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="app()" x-init="init()">
|
||||
<div class=container>
|
||||
<body>
|
||||
<header class=container>
|
||||
<div class=header>
|
||||
<a class=brand href=/ aria-label="4ev.link Home"><i data-lucide=link-2></i><span>4ev.link</span></a>
|
||||
<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>
|
||||
<a class=btn ghost href=#login><i data-lucide=log-in></i><span>Log in</span></a>
|
||||
<a class="btn primary" href=#signup><i data-lucide=user-plus></i><span>Sign up</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header class=hero>
|
||||
<h1>Get ur 4ev.link <span class=heart>❤️</span></h1>
|
||||
<div class=sub>Free. No account needed. Cloudflare-fast.</div>
|
||||
</header>
|
||||
<main class=container x-data="app()">
|
||||
<section class=hero>
|
||||
<h1>Get ur 4ev.link <span aria-hidden=true style="color:var(--heart)">❤️</span></h1>
|
||||
<div class=sub>Free works. No account needed. Cloudflare-inspired, fast, and private.</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<section class="panel" @keyup.enter="go()">
|
||||
<div class=form>
|
||||
<input class=input type=url inputmode=url autocomplete=url placeholder="Paste a long URL, e.g. https://example.com/very/long/path?with=stuff" x-model.trim=url aria-label="Long URL">
|
||||
<button class="btn primary" :disabled="!url||busy" @click=go()>
|
||||
<i :data-lucide="busy?'loader-2':'wand-2'"></i><span x-text="busy?'Shortening…':'Shorten'"></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>
|
||||
<span class=badge title="Random 4-char slug like the worker generates"><i data-lucide=hash></i><span>Free: random slug</span></span>
|
||||
<span class=badge title="Custom slugs coming soon"><i data-lucide=lock></i><span>Pro: custom URLs (soon)</span></span>
|
||||
<span class=meta x-text="'e.g. '+location.origin+'/'+ex"></span>
|
||||
</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>
|
||||
<template x-if=err><p class="meta" style="color:#b91c1c;margin:12px 0 0" x-text=err role=alert></p></template>
|
||||
|
||||
<template x-if=res>
|
||||
<div class="card" style="margin-top:12px" aria-live=polite>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div>
|
||||
<div class=meta>Short URL</div>
|
||||
<div class="mono" style="word-break:break-all"><a :href=res target=_blank rel=noopener x-text=res></a></div>
|
||||
</div>
|
||||
<p class="help" style="margin-top:8px">We’ll email you when paid plans launch. No spam, unsubscribe anytime.</p>
|
||||
<div class=copy>
|
||||
<button class=btn @click=copy()><i data-lucide=copy></i><span>Copy</span></button>
|
||||
<a class="btn" :href=res target=_blank rel=noopener><i data-lucide=external-link></i><span>Open</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hr"></div>
|
||||
<div class="row">
|
||||
<span class=small>Destination</span>
|
||||
<span class="mono" style="word-break:break-all" x-text=pretty(url)></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<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! We’ll keep you posted.')}
|
||||
});
|
||||
</script>
|
||||
<section class="grid cols-3" style="margin-top:14px">
|
||||
<div class=card><div class=row><i data-lucide=user-check></i><h2>No account needed</h2></div><div class=meta>Just paste and go. Works instantly from your phone.</div></div>
|
||||
<div class=card><div class=row><i data-lucide=zap></i><h2>Edge-fast</h2></div><div class=meta>Backed by Cloudflare KV + Workers for global speed.</div></div>
|
||||
<div class=card><div class=row><i data-lucide=shield-check></i><h2>Private</h2></div><div class=meta>We only store the URL mapping. No tracking pixels.</div></div>
|
||||
</section>
|
||||
|
||||
<section class="grid cols-2" style="margin-top:14px">
|
||||
<div class=card>
|
||||
<div class=row><i data-lucide=gift></i><h2>Free</h2></div>
|
||||
<ul class=meta>
|
||||
<li>Random 4-char slugs</li>
|
||||
<li>No login required</li>
|
||||
<li>Copy and share in 1 click</li>
|
||||
</ul>
|
||||
<div class="hr"></div>
|
||||
<button class="btn primary" @click="$el.blur();document.querySelector('input[type=url]').focus()"><i data-lucide=sparkles></i><span>Start free</span></button>
|
||||
</div>
|
||||
<div class=card>
|
||||
<div class=row><i data-lucide=badge-dollar-sign></i><h2>Pro (coming soon)</h2></div>
|
||||
<ul class=meta>
|
||||
<li>Custom slugs (your brand)</li>
|
||||
<li>Link management</li>
|
||||
<li>Analytics</li>
|
||||
</ul>
|
||||
<div class="hr"></div>
|
||||
<button class="btn" disabled><i data-lucide=clock></i><span>Notify me</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-top:14px">
|
||||
<div class=row>
|
||||
<span class=meta>Tip: press <span class=kbd>Enter</span> to shorten faster. Works cross-origin via CORS.</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="container footer">
|
||||
<div>UI inspired by Cloudflare. Colors: black/white/gray. Light theme. Mobile first.</div>
|
||||
<div class=note style="margin-top:6px"><a href="https://github.com/4ev-link/4ev.link" target=_blank rel=noopener><i data-lucide=github></i> GitHub</a> • <a href=#terms>Terms</a> • <a href=#privacy>Privacy</a></div>
|
||||
</footer>
|
||||
|
||||
<script src="//unpkg.com/alpinejs" defer></script>
|
||||
<script src="https://unpkg.com/lucide@latest" defer></script>
|
||||
<script>
|
||||
const C='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',L=4,r=()=>[...Array(L)].map(()=>C[Math.random()*C.length|0]).join('')
|
||||
function app(){return{
|
||||
url:'',res:'',err:'',busy:0,ex:r(),
|
||||
pretty:u=>{try{return new URL(u).href}catch{return u}},
|
||||
copy(){navigator.clipboard?.writeText(this.res)},
|
||||
async go(){
|
||||
this.err='';if(!this.url)return this.err='Please paste a URL.'
|
||||
try{new URL(this.url)}catch(e){return this.err='That does not look like a valid URL.'}
|
||||
this.busy=1;try{
|
||||
const x=await fetch('/',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.url})})
|
||||
if(!x.ok)throw new Error(await x.text())
|
||||
const j=await x.json();this.res=j.shortUrl||location.origin+'/'+(j.slug||'');this.ex=r()
|
||||
}catch(e){this.err=(e&&e.message)||'Something went wrong.'}finally{this.busy=0}
|
||||
}
|
||||
}}
|
||||
addEventListener('load',()=>{try{lucide.createIcons()}catch{}})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user