mirror of
https://github.com/multipleof4/4ev.link.git
synced 2026-01-13 15:57:53 +00:00
Feat: Create single-file landing page
This commit is contained in:
187
index.html
187
index.html
@@ -1,114 +1,95 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>4ev.link | A Simple, Fast URL Shortener</title>
|
<title>4ev.link - Simple & Fast URL Shortener</title>
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>
|
<script src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
:root{--bg:#fff;--fg:#111;--border:#e5e7eb;--muted-fg:#6b7280;--accent:#0051c3;--accent-fg:#fff;--error-bg:#fef2f2;--error-fg:#991b1b;--success-bg:#f0fdf4;--success-fg:#166534}
|
<style>
|
||||||
*,::before,::after{box-sizing:border-box}
|
body { font-family: 'Inter', sans-serif; }
|
||||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background-color:var(--bg);color:var(--fg);display:grid;place-items:center;min-height:100vh;padding:1rem}
|
[x-cloak] { display: none !important; }
|
||||||
main{width:100%;max-width:560px;text-align:center}
|
</style>
|
||||||
h1{font-size:2.25rem;font-weight:700;margin:0 0 .5rem}
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
p{color:var(--muted-fg);margin:0 0 2rem}
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
form{display:flex;gap:.5rem;margin-bottom:1.5rem}
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
input[type=url]{flex-grow:1;width:100%;padding:.75rem 1rem;font-size:1rem;border:1px solid var(--border);border-radius:6px;transition:box-shadow .15s ease,border-color .15s ease}
|
|
||||||
input[type=url]:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}
|
|
||||||
input:disabled{background-color:#f9fafb;cursor:not-allowed}
|
|
||||||
button{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.75rem 1.25rem;font-size:1rem;font-weight:600;border:1px solid var(--accent);border-radius:6px;background-color:var(--accent);color:var(--accent-fg);cursor:pointer;transition:background-color .15s ease}
|
|
||||||
button:hover{background-color:color-mix(in srgb,var(--accent) 85%,#000)}
|
|
||||||
button:disabled{background-color:#a1a1aa;border-color:#a1a1aa;cursor:not-allowed}
|
|
||||||
.result,.feedback{padding:1rem;border-radius:6px;text-align:left;display:flex;align-items:center;justify-content:space-between;gap:1rem;word-break:break-all}
|
|
||||||
.feedback.error{background-color:var(--error-bg);color:var(--error-fg)}
|
|
||||||
.result{background-color:var(--success-bg);border:1px solid #bbf7d0}
|
|
||||||
.result a{color:var(--success-fg);font-weight:500;text-decoration:none}
|
|
||||||
.result a:hover{text-decoration:underline}
|
|
||||||
.result button{padding:.5rem;font-size:.875rem;background-color:transparent;color:var(--success-fg);border:1px solid #6ee7b7}
|
|
||||||
.result button:hover{background-color:color-mix(in srgb,var(--success-fg) 10%,transparent)}
|
|
||||||
.result button:disabled{background-color:transparent;border-color:transparent;color:var(--success-fg)}
|
|
||||||
@keyframes spin{to{transform:rotate(360deg)}}
|
|
||||||
.spinner{animation:spin 1s linear infinite}
|
|
||||||
footer{position:absolute;bottom:1rem;color:var(--muted-fg);font-size:.875rem}
|
|
||||||
footer a{color:var(--muted-fg);text-decoration:none}
|
|
||||||
footer a:hover{text-decoration:underline}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-50 text-gray-800 antialiased">
|
||||||
<main x-data="shortener()">
|
<div class="flex flex-col items-center justify-center min-h-screen p-4">
|
||||||
<h1>4ev.link</h1>
|
<main class="w-full max-w-2xl mx-auto space-y-12">
|
||||||
<p>A simple, fast, and permanent URL shortener.</p>
|
<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>
|
||||||
|
|
||||||
<form @submit.prevent="submitUrl">
|
<div x-data="urlShortener()" class="bg-white p-6 sm:p-8 rounded-xl shadow-md">
|
||||||
<input type="url" x-model="urlInput" placeholder="https://your-very-long-url.com/to-shorten" required :disabled="loading">
|
<form @submit.prevent="shorten()">
|
||||||
<button type="submit" :disabled="loading">
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
<i x-show="!loading" data-lucide="link-2" style="width:20px;height:20px"></i>
|
<label for="url-input" class="sr-only">URL to shorten</label>
|
||||||
<i x-show="loading" class="spinner" data-lucide="loader-2" style="width:20px;height:20px"></i>
|
<input x-model="longUrl" 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-800 focus:outline-none transition">
|
||||||
<span x-text="loading ? '...' : 'Shorten'"></span>
|
<button type="submit" :disabled="loading" class="inline-flex items-center justify-center px-6 py-3 font-semibold text-white bg-gray-900 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">
|
||||||
</button>
|
<i x-show="loading" x-cloak class="animate-spin -ml-1 mr-3 h-5 w-5" data-lucide="loader-2"></i>
|
||||||
</form>
|
<span x-text="loading ? 'Working...' : 'Shorten Link'">Shorten Link</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<template x-if="error">
|
<div x-show="error" x-cloak class="mt-4 p-3 text-sm text-red-700 bg-red-100 rounded-lg" x-text="error"></div>
|
||||||
<div class="feedback error" x-text="error"></div>
|
<div x-show="result" x-cloak class="mt-4 p-3 bg-gray-100 rounded-lg">
|
||||||
</template>
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<a :href="result?.shortUrl" x-text="result?.shortUrl" target="_blank" class="font-mono text-gray-800 hover:underline truncate"></a>
|
||||||
|
<button @click="copyToClipboard(result?.shortUrl)" 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 :class="{'text-green-500': copied}" :data-lucide="copied ? 'check' : 'copy'" class="w-4 h-4"></i>
|
||||||
|
<span x-text="copied ? 'Copied!' : 'Copy'">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template x-if="shortenedUrl">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
|
||||||
<div class="result">
|
<div class="bg-white border border-gray-200 rounded-xl p-6 text-center">
|
||||||
<a :href="shortenedUrl" x-text="shortenedUrl" target="_blank" rel="noopener noreferrer"></a>
|
<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>
|
||||||
<button @click="copyToClipboard()" :disabled="copyText !== 'Copy'">
|
<h3 class="text-xl font-bold text-gray-900">Free Plan</h3>
|
||||||
<i :data-lucide="copyText === 'Copy' ? 'copy' : 'check'" style="width:16px;height:16px"></i>
|
<p class="mt-2 text-gray-600">Perfect for quick sharing. Get a random, memorable short URL instantly and for free.</p>
|
||||||
<span x-text="copyText"></span>
|
</div>
|
||||||
</button>
|
<div class="bg-white border-2 border-gray-900 rounded-xl p-6 text-center shadow-lg">
|
||||||
</div>
|
<div class="inline-flex items-center justify-center w-12 h-12 bg-gray-900 rounded-lg mb-4"><i data-lucide="crown" class="w-6 h-6 text-white"></i></div>
|
||||||
</template>
|
<h3 class="text-xl font-bold text-gray-900">Paid Plan</h3>
|
||||||
</main>
|
<p class="mt-2 text-gray-600">Brand your links with custom slugs. More control for professionals and businesses.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer class="text-center text-gray-500 text-sm !mt-16">
|
||||||
<a href="https://github.com/4ev-link/4ev.link" target="_blank" rel="noopener noreferrer">GitHub</a>
|
<p>© <span x-text="new Date().getFullYear()"></span> 4ev.link</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function shortener() {
|
document.addEventListener('alpine:init', () => {
|
||||||
return {
|
Alpine.data('urlShortener', () => ({
|
||||||
urlInput: '',
|
longUrl: '', result: null, loading: false, error: '', copied: false,
|
||||||
shortenedUrl: null,
|
async shorten() {
|
||||||
loading: false,
|
this.loading = true; this.error = ''; this.result = null; this.copied = false;
|
||||||
error: null,
|
try {
|
||||||
copyText: 'Copy',
|
new URL(this.longUrl);
|
||||||
async submitUrl() {
|
const r = await fetch('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: this.longUrl }) });
|
||||||
this.loading = true;
|
if (!r.ok) throw new Error(await r.text() || 'Server error');
|
||||||
this.error = null;
|
this.result = await r.json(); this.longUrl = '';
|
||||||
this.shortenedUrl = null;
|
} catch (e) { this.error = e.message.includes('Invalid URL') ? 'Please enter a valid URL.' : 'An unexpected error occurred.'; }
|
||||||
try {
|
finally { this.loading = false; this.$nextTick(() => lucide.createIcons()); }
|
||||||
const res = await fetch('/', {
|
},
|
||||||
method: 'POST',
|
copyToClipboard(text) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
body: JSON.stringify({ url: this.urlInput })
|
this.copied = true;
|
||||||
});
|
setTimeout(() => this.copied = false, 2000);
|
||||||
if (!res.ok) throw new Error(await res.text() || 'Failed to shorten URL');
|
this.$nextTick(() => lucide.createIcons());
|
||||||
const data = await res.json();
|
});
|
||||||
this.shortenedUrl = data.shortUrl;
|
}
|
||||||
this.urlInput = '';
|
}))
|
||||||
} catch (e) {
|
});
|
||||||
this.error = e.message;
|
lucide.createIcons();
|
||||||
} finally {
|
</script>
|
||||||
this.loading = false;
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
copyToClipboard() {
|
|
||||||
navigator.clipboard.writeText(this.shortenedUrl);
|
|
||||||
this.copyText = 'Copied!';
|
|
||||||
lucide.createIcons();
|
|
||||||
setTimeout(() => {
|
|
||||||
this.copyText = 'Copy';
|
|
||||||
lucide.createIcons();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', () => lucide.createIcons());
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user