Files
4ev.link/index.html

182 lines
12 KiB
HTML

<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>4ev.link — Short links that last</title>
<meta name="description" content="4ev.link — Free, randomized URL shortener. Clean, fast, and privacy-first."/>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Ctext y='50' x='8' font-size='48'%3E🔗%3C/text%3E%3C/svg%3E"/>
<style>
*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:16px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial,'Apple Color Emoji','Segoe UI Emoji';background:var(--bg);color:var(--fg)}
:root{--bg:#fff;--fg:#0a0a0a;--muted:#666;--line:#e6e6e6;--card:#fff;--soft:#f7f7f7;--shadow:0 1px 2px rgba(0,0,0,.06),0 10px 30px rgba(0,0,0,.08)}
[data-theme=dark]{--bg:#0b0b0b;--fg:#f4f4f4;--muted:#a0a0a0;--line:#232323;--card:#111;--soft:#151515;--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.6)}
.container{max-width:1100px;margin:0 auto;padding:20px}
.nav{display:flex;align-items:center;justify-content:space-between;padding:10px 0}
.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:20px;letter-spacing:.2px}
.badge{font:12px/1.2 ui-sans-serif;padding:3px 8px;border:1px solid var(--line);border-radius:999px;background:var(--soft);color:var(--muted)}
.tools{display:flex;gap:10px;align-items:center}
.btn{display:inline-flex;gap:8px;align-items:center;justify-content:center;white-space:nowrap;border:1px solid var(--line);background:var(--card);color:var(--fg);padding:10px 14px;border-radius:10px;box-shadow:var(--shadow);cursor:pointer;transition:.15s ease;user-select:none}
.btn:disabled{opacity:.6;cursor:not-allowed;box-shadow:none}
.btn:hover{transform:translateY(-1px)}
.btn.phantom{background:transparent;box-shadow:none}
.btn>*{pointer-events:none}
.hero{display:grid;gap:18px;justify-items:center;text-align:center;padding:40px 0}
.h1{font-size:clamp(28px,6vw,44px);letter-spacing:-.02em;line-height:1.08;margin:0}
.sub{max-width:680px;color:var(--muted)}
.form{display:grid;gap:10px;width:100%;max-width:760px;margin:10px auto 0}
.row{display:flex;gap:10px;align-items:center}
.input{flex:1;border:1px solid var(--line);background:var(--card);padding:14px 14px;border-radius:12px;outline:none;color:var(--fg);box-shadow:var(--shadow)}
.input::placeholder{color:#9a9a9a}
.kbd{font:12px/1 ui-monospace,Menlo,Consolas,monospace;border:1px solid var(--line);padding:6px 8px;border-radius:10px;background:var(--soft);color:var(--muted)}
.note{font-size:12px;color:var(--muted)}
.grid{display:grid;gap:12px;margin:26px auto;grid-template-columns:1fr;max-width:760px}
@media(min-width:760px){.grid{grid-template-columns:repeat(3,1fr)}}
.card{border:1px solid var(--line);background:var(--card);border-radius:14px;padding:16px;box-shadow:var(--shadow);display:grid;gap:8px}
.card h3{margin:0;font-size:15px}
.card p{margin:0;color:var(--muted);font-size:13px}
.result{display:grid;gap:10px;max-width:760px;margin:18px auto 0}
.out{display:flex;gap:10px;align-items:center;justify-content:space-between;border:1px dashed var(--line);background:var(--soft);padding:12px 12px;border-radius:12px}
.link{overflow:auto;white-space:nowrap;font:600 14px/1.2 ui-monospace,Menlo,Consolas,monospace}
.list{display:grid;gap:8px;margin:20px auto 0;max-width:760px}
.item{display:flex;gap:12px;align-items:center;justify-content:space-between;border:1px solid var(--line);background:var(--card);padding:12px;border-radius:12px}
.item a{color:inherit;text-decoration:none}
.mono{font:12px/1.2 ui-monospace,Menlo,Consolas,monospace;color:var(--muted)}
.hr{height:1px;background:var(--line);border:0;margin:26px 0}
.footer{display:flex;gap:10px;align-items:center;justify-content:space-between;color:var(--muted);padding:24px 0}
.toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow);color:var(--fg);padding:10px 14px;border-radius:10px}
.skel{animation:sh 1.2s infinite linear;background:linear-gradient(90deg,transparent,rgba(128,128,128,.08),transparent);background-size:200% 100%}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
.bgfx:before{content:"";position:fixed;inset:-20% -10% auto -10%;height:50vh;background:radial-gradient(60% 60% at 50% 0,rgba(0,0,0,.06),transparent);pointer-events:none}
[data-theme=dark] .bgfx:before{background:radial-gradient(60% 60% at 50% 0,rgba(255,255,255,.04),transparent)}
a{color:inherit}
</style>
</head>
<body class="bgfx">
<div class="container" x-data="app()" x-init="init()">
<header class="nav">
<div class="brand">
<i data-lucide="link-2" style="width:22px;height:22px"></i>
<span>4ev.link</span>
<span class="badge">Free • Randomized</span>
</div>
<div class="tools">
<button class="btn phantom" @click="toggleTheme()" :aria-label="t==='dark'?'Switch to light':'Switch to dark'">
<i :data-lucide="t==='dark'?'sun':'moon'" style="width:18px;height:18px"></i>
</button>
<a href="#" class="btn"><i data-lucide="log-in" style="width:18px;height:18px"></i><span>Sign in</span></a>
</div>
</header>
<section class="hero">
<h1 class="h1">Short links, built to last</h1>
<p class="sub">Clean, fast, privacy-first URL shortener. Free plan with randomized slugs only (e.g., 4ev.link/xY7z). No custom slugs, no ads.</p>
<div class="form" @submit.prevent>
<div class="row">
<input class="input" x-model="url" type="url" inputmode="url" placeholder="Paste a long URL, e.g. https://example.com/very/long/link" aria-label="Long URL" @keydown.enter.prevent="shorten()">
<button class="btn" @click="shorten()" :disabled="busy">
<template x-if="!busy"><i data-lucide="scissors" style="width:18px;height:18px"></i></template>
<template x-if="busy"><i data-lucide="loader-2" style="width:18px;height:18px;animation:spin .9s linear infinite"></i></template>
<span x-text="busy?'Working…':'Shorten'"></span>
</button>
</div>
<div class="row" style="justify-content:space-between">
<span class="note">Free tier: randomized links, basic usage. No login required.</span>
<span class="kbd">Enter ↵</span>
</div>
</div>
<div class="result" x-show="short" x-cloak>
<div class="out">
<div class="link"><a :href="short" target="_blank" rel="noopener" x-text="short"></a></div>
<div class="row" style="gap:8px">
<button class="btn" @click="copy(short)"><i data-lucide="copy" style="width:16px;height:16px"></i><span>Copy</span></button>
<a class="btn" :href="short" target="_blank" rel="noopener"><i data-lucide="external-link" style="width:16px;height:16px"></i><span>Open</span></a>
</div>
</div>
<small class="note">Tip: Bookmark this page to keep your recent links on this device.</small>
</div>
</section>
<section class="grid">
<article class="card">
<div class="row"><i data-lucide="zap" style="width:18px;height:18px"></i><h3>Fast redirects</h3></div>
<p>Minimal overhead, instant responses. Built for speed on modern edge platforms.</p>
</article>
<article class="card">
<div class="row"><i data-lucide="shield" style="width:18px;height:18px"></i><h3>Privacy-first</h3></div>
<p>No ad pages. No tracking pixels. Just clean redirects.</p>
</article>
<article class="card">
<div class="row"><i data-lucide="wand-2" style="width:18px;height:18px"></i><h3>Randomized slugs</h3></div>
<p>Free plan generates secure, random slugs. Custom branded links coming soon.</p>
</article>
</section>
<hr class="hr"/>
<section>
<div class="row" style="justify-content:space-between;align-items:center;margin:0 auto 8px;max-width:760px">
<h3 style="margin:0;font-size:16px">Recent on this device</h3>
<span class="note" x-show="!links.length">No links yet</span>
</div>
<div class="list">
<template x-for="(l,i) in links" :key="l.slug">
<div class="item">
<div style="min-width:0">
<div style="display:flex;gap:8px;align-items:center;min-width:0">
<i data-lucide="link" style="width:16px;height:16px"></i>
<a :href="`${base}/${l.slug}`" target="_blank" rel="noopener" class="link" x-text="`${base}/${l.slug}`"></a>
</div>
<div class="mono" style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch" x-text="l.target"></span>
<span></span>
<span><i data-lucide="clock" style="width:14px;height:14px;vertical-align:-2px"></i> <span x-text="new Date(l.created).toLocaleString()"></span></span>
</div>
</div>
<div class="row" style="gap:8px">
<button class="btn" @click="copy(`${base}/${l.slug}`)" title="Copy">
<i data-lucide="copy" style="width:16px;height:16px"></i>
</button>
<a class="btn" :href="`${base}/${l.slug}`" target="_blank" rel="noopener" title="Open">
<i data-lucide="external-link" style="width:16px;height:16px"></i>
</a>
<button class="btn" @click="del(i)" title="Remove">
<i data-lucide="trash-2" style="width:16px;height:16px"></i>
</button>
</div>
</div>
</template>
</div>
</section>
<footer class="footer">
<div style="display:flex;gap:10px;align-items:center">
<i data-lucide="globe" style="width:16px;height:16px"></i>
<span>© <span x-text="new Date().getFullYear()"></span> 4ev.link</span>
</div>
<div class="note">Free plan • Randomized links • Black/White/Gray UI</div>
</footer>
</div>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
const app=()=>({t:localStorage.getItem('theme')||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'),url:'',busy:0,short:'',links:JSON.parse(localStorage.getItem('4ev.links')||'[]'),base:'https://4ev.link',msg:'',
init(){document.documentElement.setAttribute('data-theme',this.t);this.freshIcons()},
setTheme(v){this.t=v;localStorage.setItem('theme',v);document.documentElement.setAttribute('data-theme',v);this.freshIcons()},
toggleTheme(){this.setTheme(this.t==='light'?'dark':'light')},
val(u){try{new URL(u);return 1}catch(e){return 0}},
async shorten(){if(this.busy||!this.val(this.url))return this.toast('Enter a valid URL');this.busy=1;this.freshIcons();try{const r=await fetch(this.base,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.url.trim()})});if(!r.ok)throw'';const d=await r.json();this.short=d.shortUrl;this.links.unshift({slug:d.slug,target:d.target,created:Date.now()});this.links.splice(20);localStorage.setItem('4ev.links',JSON.stringify(this.links));this.url='';this.toast('Link created')}catch(e){this.toast('Failed to shorten link')}finally{this.busy=0;this.freshIcons()}},
copy(txt){navigator.clipboard?.writeText(txt).then(()=>this.toast('Copied')).catch(()=>{let t=document.createElement('textarea');t.value=txt;document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();this.toast('Copied')})},
toast(m){this.msg=m;this.freshIcons();clearTimeout(this._to);this._to=setTimeout(()=>this.msg='',2000)},
del(i){this.links.splice(i,1);localStorage.setItem('4ev.links',JSON.stringify(this.links));this.freshIcons()},
freshIcons(){this.$nextTick(()=>(lucide?.createIcons()))}
});
document.addEventListener('DOMContentLoaded',()=>{lucide.createIcons()});
</script>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
<div x-data="{m:''}" x-init="$watch('$root.__x.$data.msg',v=>{m=v})">
<div class="toast" x-cloak x-show="$root.__x.$data.msg" x-text="$root.__x.$data.msg" role="status" aria-live="polite"></div>
</div>
</body>
</html>