358 lines
14 KiB
Text
358 lines
14 KiB
Text
package pages
|
|
|
|
import (
|
|
"maot-shortner/web/templates/components/button"
|
|
"maot-shortner/web/templates/components/icon"
|
|
"maot-shortner/web/templates/components/input"
|
|
"maot-shortner/web/templates/layouts"
|
|
)
|
|
|
|
templ Home() {
|
|
@layouts.MainLayout() {
|
|
<div x-data="shortener()" class="min-h-full flex justify-center items-center flex-col gap-6 p-6 w-full">
|
|
<div class="animate-pulse-slow hover:scale-110 transition-transform duration-300">
|
|
@icon.Link(icon.Props{Class: "w-24 h-24"})
|
|
</div>
|
|
<h2
|
|
class="text-4xl font-bold transform transition-all duration-1000 ease-out"
|
|
x-data="{ loaded: false }"
|
|
x-init="setTimeout(() => loaded = true, 200)"
|
|
:class="loaded ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'"
|
|
>shorten your urls...</h2>
|
|
<form
|
|
class="flex gap-4 items-center justify-center mt-2 px-4 w-full max-w-2xl transform transition-all duration-300"
|
|
:class="loading ? 'scale-[0.98] opacity-80' : 'scale-100 opacity-100'"
|
|
@submit.prevent="submit"
|
|
>
|
|
@input.Input(input.Props{
|
|
ID: "URL",
|
|
Type: input.TypeURL,
|
|
Placeholder: "https://very-long-url.com/",
|
|
Attributes: map[string]any{
|
|
"x-model": "longUrl",
|
|
"class": "transition-all duration-300",
|
|
":class": "loading ? 'animate-pulse' : ''",
|
|
},
|
|
})
|
|
@button.Button(button.Props{
|
|
ID: "Shorten",
|
|
Variant: button.VariantGhost,
|
|
Type: button.TypeSubmit,
|
|
Attributes: map[string]any{
|
|
":disabled": "loading || !isValid",
|
|
"class": "disabled:opacity-50",
|
|
},
|
|
}) {
|
|
<span x-show="!loading" class="inline-flex items-center gap-2">
|
|
@icon.Send(icon.Props{Size: 20})
|
|
</span>
|
|
<span x-show="loading" class="inline-flex items-center gap-2">
|
|
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"></path>
|
|
</svg>
|
|
<span>Encurtando...</span>
|
|
</span>
|
|
}
|
|
</form>
|
|
<div
|
|
id="toast-container"
|
|
class="fixed bottom-4 right-4 z-50 w-[min(380px,92vw)] flex flex-col-reverse items-end gap-2 pointer-events-none"
|
|
x-cloak
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
>
|
|
<template x-for="t in toasts" :key="t.id">
|
|
<div
|
|
class="group relative overflow-hidden rounded-2xl border shadow-md backdrop-blur-sm pointer-events-auto"
|
|
x-show="t.show"
|
|
:class="{
|
|
'border-emerald-300/40 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-50': t.variant === 'success',
|
|
'border-red-300/40 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-50': t.variant === 'error',
|
|
'border-sky-300/40 bg-sky-50 text-sky-900 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-50': t.variant === 'info',
|
|
'border-amber-300/40 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-50': t.variant === 'warning',
|
|
'border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50': !['success','error','info','warning'].includes(t.variant)
|
|
}"
|
|
x-transition:enter="transition transform ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 translate-y-2"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
x-transition:leave="transition transform ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 translate-y-0"
|
|
x-transition:leave-end="opacity-0 translate-y-2"
|
|
>
|
|
<button
|
|
class="absolute right-2 top-2 rounded-md p-1 opacity-70 hover:opacity-100"
|
|
@click="hideToast(t.id)"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
></path>
|
|
</svg>
|
|
</button>
|
|
<div class="flex gap-3 p-4 pr-9">
|
|
<div class="mt-0.5">
|
|
<template x-if="t.variant === 'success'">
|
|
<svg class="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="t.variant === 'error'">
|
|
<svg class="h-5 w-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="t.variant === 'warning'">
|
|
<svg class="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
|
></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="t.variant === 'info'">
|
|
<svg class="h-5 w-5 text-sky-600 dark:text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"
|
|
></path>
|
|
</svg>
|
|
</template>
|
|
</div>
|
|
<div class="flex-1">
|
|
<p class="font-semibold leading-5" x-text="t.title"></p>
|
|
<p class="mt-0.5 text-sm opacity-90" x-text="t.message"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<template x-if="result">
|
|
<div
|
|
class="w-full max-w-2xl"
|
|
x-show="result"
|
|
x-cloak
|
|
x-transition:enter="transition transform ease-out duration-500"
|
|
x-transition:enter-start="opacity-0 scale-95 translate-y-8"
|
|
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
|
x-transition:leave="transition transform ease-in duration-300"
|
|
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
|
|
x-transition:leave-end="opacity-0 scale-95 translate-y-4"
|
|
>
|
|
<div class="group relative overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-lg hover:scale-[1.02] transition-all duration-300 transform">
|
|
<div
|
|
class="flex items-center gap-3 px-6 py-4 border-b bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-950/20 dark:to-green-950/20"
|
|
x-data="{ animate: false }"
|
|
x-init="setTimeout(() => animate = true, 100)"
|
|
>
|
|
<div
|
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/50 transform transition-all duration-700"
|
|
:class="animate ? 'scale-100 rotate-0' : 'scale-0 rotate-180'"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 text-emerald-600 dark:text-emerald-400 transform transition-all duration-500 delay-50"
|
|
:class="animate ? 'scale-100 opacity-100' : 'scale-0 opacity-0'"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
|
</svg>
|
|
</div>
|
|
<div
|
|
class="transform transition-all duration-500 delay-100"
|
|
:class="animate ? 'translate-x-0 opacity-100' : 'translate-x-4 opacity-0'"
|
|
>
|
|
<h3 class="font-semibold text-emerald-900 dark:text-emerald-100">URL Successfully Shortened</h3>
|
|
<p class="text-sm text-emerald-700 dark:text-emerald-300">Created <span x-text="formatDate(result.created_at)"></span></p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="p-6 space-y-4"
|
|
x-data="{ animate: false }"
|
|
x-init="setTimeout(() => animate = true, 100)"
|
|
>
|
|
<div
|
|
class="space-y-2 transform transition-all duration-500 delay-100"
|
|
:class="animate ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'"
|
|
>
|
|
<label class="text-sm font-medium text-muted-foreground">Short URL</label>
|
|
<div class="flex items-center gap-2 p-3 bg-muted/50 rounded-md border hover:bg-muted/70 transition-colors duration-200">
|
|
<div class="flex h-8 w-8 items-center justify-center rounded bg-background border">
|
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
</svg>
|
|
</div>
|
|
<a
|
|
class="flex-1 font-mono text-sm font-medium text-primary hover:underline break-all transition-colors duration-200 hover:text-primary/80"
|
|
:href="result.short_url"
|
|
target="_blank"
|
|
rel="noopener"
|
|
x-text="result.short_url"
|
|
></a>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="space-y-2 transform transition-all duration-500 delay-400"
|
|
:class="animate ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'"
|
|
>
|
|
<label class="text-sm font-medium text-muted-foreground">Original URL</label>
|
|
<div class="p-3 bg-muted/30 rounded-md border-dashed border">
|
|
<p class="text-sm text-muted-foreground break-all font-mono" x-text="result.long_url"></p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="flex gap-2 pt-2 transform transition-all duration-500 delay-500"
|
|
:class="animate ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'"
|
|
x-data="{ copied: false }"
|
|
>
|
|
<button
|
|
class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 text-primary-foreground rounded-md text-sm font-medium hover:scale-105 active:scale-95 transition-all duration-200 transform"
|
|
:class="copied ? 'bg-emerald-500 hover:bg-emerald-600' : 'bg-primary hover:bg-primary/90'"
|
|
type="button"
|
|
@click="copy(result.short_url); copied = true; setTimeout(() => copied = false, 2000)"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 transition-all duration-200"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<span x-text="copied ? 'Copied!' : 'Copy Link'" class="transition-all duration-200"></span>
|
|
</button>
|
|
<a
|
|
class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground hover:scale-105 active:scale-95 rounded-md text-sm font-medium transition-all duration-200 transform"
|
|
:href="result.short_url"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
|
</svg>
|
|
Open Link
|
|
</a>
|
|
<button
|
|
class="inline-flex items-center justify-center h-9 px-3 py-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground hover:scale-105 active:scale-95 rounded-md text-sm font-medium transition-all duration-200 transform"
|
|
type="button"
|
|
@click="result = null"
|
|
title="Clear result"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('shortener', () => ({
|
|
longUrl: '',
|
|
loading: false,
|
|
result: null,
|
|
toasts: [],
|
|
hideToast(id) {
|
|
const idx = this.toasts.findIndex(t => t.id === id);
|
|
if (idx === -1) return;
|
|
this.toasts[idx].show = false;
|
|
setTimeout(() => {
|
|
this.toasts = this.toasts.filter(t => t.id !== id);
|
|
}, 250);
|
|
},
|
|
get isValid() {
|
|
try {
|
|
const u = new URL(this.longUrl);
|
|
return ['http:', 'https:'].includes(u.protocol);
|
|
} catch { return false; }
|
|
},
|
|
async submit() {
|
|
if (!this.isValid || this.loading) return;
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch('/api/link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: this.longUrl })
|
|
});
|
|
if (!res.ok) throw new Error('Falha ao encurtar');
|
|
const data = await res.json();
|
|
|
|
this.result = data;
|
|
this.result.short_url = `${window.location.protocol}//${window.location.host}/${data.short_url}`;
|
|
|
|
this.triggerSuccessAnimation();
|
|
|
|
await this.copy(this.result.short_url);
|
|
|
|
this.toast({
|
|
title: 'Shortened!',
|
|
message: 'Short URL copied to clipboard.',
|
|
variant: 'success'
|
|
});
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
this.toast({
|
|
title: 'Error',
|
|
message: 'Failed to shorten the link.',
|
|
variant: 'error'
|
|
});
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
async copy(text) {
|
|
await navigator.clipboard.writeText(text);
|
|
},
|
|
toast({ title, message, variant = 'success', timeout = 3000 }) {
|
|
const id = Math.random().toString(36).slice(2);
|
|
const toast = { id, title, message, variant, show: false };
|
|
this.toasts.push(toast);
|
|
|
|
setTimeout(() => {
|
|
const toastIndex = this.toasts.findIndex(t => t.id === id);
|
|
if (toastIndex !== -1) {
|
|
this.toasts[toastIndex].show = true;
|
|
}
|
|
}, 10);
|
|
|
|
if (timeout > 0) {
|
|
setTimeout(() => this.hideToast(id), timeout);
|
|
}
|
|
},
|
|
formatDate(iso) {
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString();
|
|
} catch { return iso; }
|
|
},
|
|
triggerSuccessAnimation() {
|
|
// Add a temporary success class to body for global animation effects
|
|
document.body.classList.add('success-animation');
|
|
setTimeout(() => {
|
|
document.body.classList.remove('success-animation');
|
|
}, 1000);
|
|
},
|
|
}))
|
|
})
|
|
</script>
|
|
}
|
|
}
|