add(frontend): remove htmx import and add animations
This commit is contained in:
parent
96a5d9f230
commit
5adada0422
4 changed files with 1285 additions and 33 deletions
|
|
@ -140,3 +140,131 @@
|
|||
"calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Custom animations for the URL shortener */
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes success-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgb(34, 197, 94, 0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 10px rgb(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgb(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) translateX(0) translateY(0) rotateZ(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) translateX(var(--end-x)) translateY(var(--end-y)) rotateZ(var(--rotation));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes celebrate-bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in-scale {
|
||||
animation: fade-in-scale 0.4s ease-out;
|
||||
}
|
||||
|
||||
.animate-success-pulse {
|
||||
animation: success-pulse 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-celebrate-bounce {
|
||||
animation: celebrate-bounce 1s ease-in-out;
|
||||
}
|
||||
|
||||
body.success-animation h2 {
|
||||
animation: celebrate-bounce 1s ease-in-out;
|
||||
}
|
||||
|
||||
.delay-100 { animation-delay: 100ms; }
|
||||
.delay-200 { animation-delay: 200ms; }
|
||||
.delay-300 { animation-delay: 300ms; }
|
||||
.delay-400 { animation-delay: 400ms; }
|
||||
.delay-500 { animation-delay: 500ms; }
|
||||
.delay-600 { animation-delay: 600ms; }
|
||||
.delay-700 { animation-delay: 700ms; }
|
||||
.delay-800 { animation-delay: 800ms; }
|
||||
.delay-900 { animation-delay: 900ms; }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,351 @@ import (
|
|||
|
||||
templ Home() {
|
||||
@layouts.MainLayout() {
|
||||
<div class="h-full flex justify-center items-center flex-col">
|
||||
// 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"})
|
||||
<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">
|
||||
</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 id="toast-container"></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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue