add(frontend): remove htmx import and add animations

This commit is contained in:
maotovisk 2025-08-08 15:31:36 -03:00
parent 96a5d9f230
commit 5adada0422
4 changed files with 1285 additions and 33 deletions

View file

@ -3,7 +3,6 @@ package layouts
import (
"maot-shortner/web/templates/components/input"
"maot-shortner/web/templates/components/label"
"maot-shortner/web/templates/components/toast"
"maot-shortner/web/templates/modules"
)
@ -17,9 +16,6 @@ templ MainLayout() {
<link rel="icon" type="image/x-icon" href="/static/img/favicon.ico"/>
@input.Script()
@label.Script()
@toast.Script()
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js"></script>
</head>
<body class="h-full">
<div class="h-full flex flex-col">
@ -28,6 +24,7 @@ templ MainLayout() {
{ children... }
</main>
</div>
<script src="/static/js/alpine.min.js"></script>
</body>
</html>
}

View file

@ -9,24 +9,351 @@ import (
templ Home() {
@layouts.MainLayout() {
<div class="h-full flex justify-center items-center flex-col">
@icon.Link(icon.Props{Class: "w-24 h-24"})
<h2 class="text-4xl font-bold py-4">shorten your urls...</h2>
<div class="flex gap-4 items-center justify-center mt-4 px-4 w-full max-w-2xl">
// Componente Alpine
<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",
},
}) {
@icon.Send(icon.Props{Size: 20})
<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>
<div id="toast-container"></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>
}
}