mirror of
https://github.com/multipleof4/4ev.link.git
synced 2026-01-13 15:57:53 +00:00
Fix: Resolve button click and icon rendering race condition
This commit is contained in:
274
index.html
274
index.html
@@ -1,113 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>4ev.link - Simple & Fast URL Shortener</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
<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;700&display=swap" rel="stylesheet">
|
||||
<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="bg-gray-50 text-gray-800 antialiased">
|
||||
<div class="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<main class="w-full max-w-2xl mx-auto space-y-12">
|
||||
<header class="text-center">
|
||||
<h1 class="text-5xl font-bold tracking-tight text-gray-900">4ev.link</h1>
|
||||
<p class="mt-3 text-lg text-gray-600">Shorten. Share. Forever.</p>
|
||||
</header>
|
||||
<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>
|
||||
|
||||
<div class="bg-white p-6 sm:p-8 rounded-xl shadow-md">
|
||||
<form id="shorten-form">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<label for="url-input" class="sr-only">URL to shorten</label>
|
||||
<input type="url" id="url-input" placeholder="https://your-long-url.com/goes-here" required class="flex-grow w-full px-4 py-3 bg-gray-100 border border-gray-200 rounded-lg focus:ring-2 focus:ring-gray-600 focus:outline-none transition">
|
||||
<button type="submit" id="submit-btn" class="inline-flex items-center justify-center px-6 py-3 font-semibold text-white bg-gray-800 rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-800 focus:ring-offset-2 disabled:bg-gray-400 transition">
|
||||
<i id="loader" class="hidden animate-spin -ml-1 mr-3 h-5 w-5" data-lucide="loader-2"></i>
|
||||
<span id="btn-text">Shorten Link</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="error-box" class="hidden mt-4 p-3 text-sm text-red-700 bg-red-100 rounded-lg"></div>
|
||||
<div id="result-box" class="hidden mt-4 p-3 bg-gray-100 rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 text-center">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg mb-4"><i data-lucide="shuffle" class="w-6 h-6 text-gray-700"></i></div>
|
||||
<h3 class="text-xl font-bold text-gray-800">Free Plan</h3>
|
||||
<p class="mt-2 text-gray-600 font-mono">Perfect for quick sharing. Get a random, memorable short URL instantly and for free.</p>
|
||||
</div>
|
||||
<div class="bg-white border-2 border-gray-800 rounded-xl p-6 text-center shadow-lg">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-gray-800 rounded-lg mb-4"><i data-lucide="crown" class="w-6 h-6 text-white"></i></div>
|
||||
<h3 class="text-xl font-bold text-gray-800">Paid Plan</h3>
|
||||
<p class="mt-2 text-gray-600 font-mono">Brand your links with custom slugs. More control for professionals and businesses.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="text-center text-gray-500 text-sm !mt-16">
|
||||
<p>© <span id="year"></span> 4ev.link</p>
|
||||
</footer>
|
||||
</main>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const D=document, form=D.getElementById('shorten-form'), urlIn=D.getElementById('url-input'),
|
||||
submitBtn=D.getElementById('submit-btn'), btnTxt=D.getElementById('btn-text'),
|
||||
loader=D.getElementById('loader'), errBox=D.getElementById('error-box'),
|
||||
resBox=D.getElementById('result-box');
|
||||
<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>
|
||||
|
||||
const copy = (txt, btn) => {
|
||||
navigator.clipboard.writeText(txt).then(() => {
|
||||
btn.innerHTML = `<i data-lucide="check" class="w-4 h-4 text-green-500"></i><span>Copied!</span>`;
|
||||
lucide.createIcons();
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = `<i data-lucide="copy" class="w-4 h-4"></i><span>Copy</span>`;
|
||||
lucide.createIcons();
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
<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>
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
submitBtn.disabled=true; loader.classList.remove('hidden'); btnTxt.textContent='Working...';
|
||||
errBox.classList.add('hidden'); resBox.classList.add('hidden');
|
||||
<hr class="hr"/>
|
||||
|
||||
try {
|
||||
const url = urlIn.value; new URL(url);
|
||||
const r = await fetch('/', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
||||
if (!r.ok) throw new Error(await r.text() || 'Server error');
|
||||
const d = await r.json();
|
||||
|
||||
resBox.innerHTML = `
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<a href="${d.shortUrl}" target="_blank" class="font-mono text-gray-700 hover:underline truncate">${d.shortUrl}</a>
|
||||
<button id="copy-btn" class="flex-shrink-0 inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-md hover:bg-gray-50">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i><span>Copy</span>
|
||||
</button>
|
||||
</div>`;
|
||||
resBox.classList.remove('hidden');
|
||||
D.getElementById('copy-btn').onclick = function(){ copy(d.shortUrl, this) };
|
||||
urlIn.value = '';
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
errBox.textContent = err.message.includes('Invalid URL') ? 'Please enter a valid URL.' : 'An unexpected error occurred.';
|
||||
errBox.classList.remove('hidden');
|
||||
} finally {
|
||||
submitBtn.disabled=false; loader.classList.add('hidden'); btnTxt.textContent='Shorten Link';
|
||||
}
|
||||
});
|
||||
|
||||
D.getElementById('year').textContent = new Date().getFullYear();
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user