From b37b6bc2b9ce3972b3e99327135e70c3e8898081 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 24 Jul 2026 13:34:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20premium=20polish=20pass=20=E2=80=94=20t?= =?UTF-8?q?yping=20effect,=20animated=20counters,=20ambient=20orbs,=20inpu?= =?UTF-8?q?t=20glow,=20show/hide=20password,=20redesigned=20settings,=20Re?= =?UTF-8?q?veal=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TypewriterText component for hero heading animation - Add AnimatedCounter with scroll-triggered count-up - Add AmbientOrbs with floating gradient orbs in hero - Add input-glow focus effect, btn-shine hover effect CSS - Add show/hide password toggle on login, register, and home login forms - Redesign settings page with premium gradient cards and icon headers - Polish /me dashboard with Reveal animations, btn-shine, input-glow - Add animate-count CSS keyframe --- src/app/(site)/me/page.tsx | 19 +- src/app/(site)/page.tsx | 16 +- src/app/(site)/settings/page.tsx | 287 ++++++++++++++++++------ src/app/globals.css | 63 ++++++ src/components/ambient-orbs.tsx | 66 ++++++ src/components/animated-counter.tsx | 46 ++++ src/components/auth/home-login-form.tsx | 68 +++--- src/components/auth/login-form.tsx | 22 +- src/components/auth/register-form.tsx | 90 +++++--- src/components/typewriter-text.tsx | 42 ++++ 10 files changed, 569 insertions(+), 150 deletions(-) create mode 100644 src/components/ambient-orbs.tsx create mode 100644 src/components/animated-counter.tsx create mode 100644 src/components/typewriter-text.tsx diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 02e45cf3..49771669 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import type { CSSProperties, ReactNode } from "react"; import { claimReferral } from "@/actions/referral"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; +import { Reveal } from "@/components/motion-reveal"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; @@ -189,6 +190,7 @@ export default async function MePage({ ) : null} {/* ── Profile header ── */} +
+
{/* ── Enter hotel ── */} + Enter {hotelName} + {/* ── Stat tiles ── */} -
+ +
{[ { value: user.credits.toLocaleString(), label: "Credits" }, { value: `Rank ${user.rank}`, label: "Hotel rank" }, @@ -285,8 +291,10 @@ export default async function MePage({
))}
+ {/* ── Online friends ── */} +
)}
+
{/* ── Referral card ── */} +
+
); } catch { diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 6358f876..f3458ac5 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -16,6 +16,9 @@ import { LanguageSwitcher } from "@/components/language-switcher"; import { HomeLoginForm } from "@/components/auth/home-login-form"; import { Reveal } from "@/components/motion-reveal"; import { Clock } from "@/components/clock"; +import { TypewriterText } from "@/components/typewriter-text"; +import { AnimatedCounter } from "@/components/animated-counter"; +import { AmbientOrbs } from "@/components/ambient-orbs"; export const revalidate = 120; @@ -125,7 +128,8 @@ export default async function Home() { background: "linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 90%, transparent) 0%, color-mix(in srgb, var(--color-surface) 35%, transparent) 50%, color-mix(in srgb, var(--color-surface) 10%, transparent) 100%)", }} /> -
+ +
- {tp("welcome", { hotelName })} +

{[ - { value: online.toLocaleString(), label: tp("statsOnline"), icon: "/assets/images/icons/online-friends.png" }, - { value: users.toLocaleString(), label: tp("statsCitizens"), icon: "/assets/images/icons/friends.png" }, - { value: rooms.toLocaleString(), label: tp("statsRooms"), icon: "/assets/images/icons/catalog.png" }, + { value: online, label: tp("statsOnline"), icon: "/assets/images/icons/online-friends.png" }, + { value: users, label: tp("statsCitizens"), icon: "/assets/images/icons/friends.png" }, + { value: rooms, label: tp("statsRooms"), icon: "/assets/images/icons/catalog.png" }, ].map((s) => (
- {s.value} +
{s.label} diff --git a/src/app/(site)/settings/page.tsx b/src/app/(site)/settings/page.tsx index 27eccd51..3c86ae16 100644 --- a/src/app/(site)/settings/page.tsx +++ b/src/app/(site)/settings/page.tsx @@ -1,8 +1,9 @@ +import Image from "next/image"; import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { updateMotto } from "@/actions/user-settings"; -import { ContentCard } from "@/components/public/ui"; +import { Reveal } from "@/components/motion-reveal"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; import { prisma } from "@/lib/prisma"; @@ -39,95 +40,233 @@ export default async function SettingsPage() { imagerBase = (await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase; } catch { - // fall back to the default imager base + /* fall back to default */ } const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" }); return ( -
- - - +
+ {/* Header */} +
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${user.username} -
-

- {user.motto || {t("noMotto")}} -

-

- {user.mail || t("noEmail")} -

+
+
+ +
+
+

+ {t("accountSettings")} +

+

+ {t("accountSettingsSubtitle")} +

+
- + - -
+
- -
- +
+ +

+ {t("publicProfile")} +

+
+
+
+
+ {`${user.username} +
+
+
+

+ {user.username} +

+

+ {user.motto || {t("noMotto")}} +

+

+ {user.mail || t("noEmail")} +

+
- - - -
- -

- {t("securityDescription")} -

- - {t("twoFactorLink")} - -
-
-
+ + + {/* Motto form */} + +
+
+ +

+ {t("changeMotto")} +

+
+
+

+ {t("changeMottoSubtitle")} +

+
+ +
+ +
+
+
+
+
+ + {/* Security card */} + +
+
+ +

+ {t("securityTitle")} +

+
+
+

+ {t("securityDescription")} +

+ + {t("twoFactorLink")} + +
+
+
+ + {/* Sessions link */} + + +
+
+
+ +
+
+ + Session Management + +

+ View active sessions and sign out everywhere +

+
+
+ + → + +
+ +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 7aba17bc..9e4200e9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1742,6 +1742,69 @@ details[open] > summary .details-open\:rotate-180 { border-radius: calc(var(--radius-lg) - 1px); } +/* Input focus glow */ +.input-glow:focus { + outline: none; + border-color: var(--color-primary) !important; + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent), + 0 0 20px color-mix(in srgb, var(--color-primary) 10%, transparent); +} +.input-glow { + transition: + border-color 0.25s ease, + box-shadow 0.25s ease; +} + +/* Typing cursor blink */ +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +.typing-cursor::after { + content: "|"; + animation: blink 0.8s step-end infinite; + margin-left: 2px; + font-weight: 300; + color: var(--color-primary); +} + +/* Number count-up */ +@keyframes count-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-count { + animation: count-up 0.6s ease-out both; +} + +/* Premium button shine */ +.btn-shine { + position: relative; + overflow: hidden; +} +.btn-shine::after { + content: ""; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + transparent 0%, + transparent 35%, + color-mix(in srgb, white 8%, transparent) 50%, + transparent 65%, + transparent 100% + ); + transform: rotate(25deg); + transition: all 0.5s ease; + pointer-events: none; +} +.btn-shine:hover::after { + left: 100%; +} + /* Staggered children animation */ .stagger-children > * { animation: fade-in-up 0.5s ease-out both; diff --git a/src/components/ambient-orbs.tsx b/src/components/ambient-orbs.tsx new file mode 100644 index 00000000..b0b2e928 --- /dev/null +++ b/src/components/ambient-orbs.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface Orb { + id: number; + x: number; + y: number; + size: number; + delay: number; + duration: number; + color: string; +} + +export function AmbientOrbs() { + const [orbs, setOrbs] = useState([]); + + useEffect(() => { + const colors = [ + "var(--color-primary)", + "var(--color-accent)", + "color-mix(in srgb, var(--color-primary) 60%, var(--color-accent))", + ]; + const items: Orb[] = Array.from({ length: 3 }, (_, i) => ({ + id: i, + x: 15 + Math.random() * 70, + y: 10 + Math.random() * 70, + size: 120 + Math.random() * 180, + delay: Math.random() * 3, + duration: 8 + Math.random() * 6, + color: colors[i], + })); + setOrbs(items); + }, []); + + if (orbs.length === 0) return null; + + return ( +
+ {orbs.map((orb) => ( +
+ ))} + +
+ ); +} diff --git a/src/components/animated-counter.tsx b/src/components/animated-counter.tsx new file mode 100644 index 00000000..f0fd055b --- /dev/null +++ b/src/components/animated-counter.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { motion, useInView } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; + +export function AnimatedCounter({ + value, + suffix = "", +}: { + value: number; + suffix?: string; +}) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: "-50px" }); + const [display, setDisplay] = useState(0); + + useEffect(() => { + if (!isInView) return; + const duration = 1200; + const steps = 30; + const increment = value / steps; + let current = 0; + let step = 0; + const id = setInterval(() => { + step++; + current = Math.min(Math.round(increment * step), value); + setDisplay(current); + if (step >= steps) { + clearInterval(id); + setDisplay(value); + } + }, duration / steps); + return () => clearInterval(id); + }, [isInView, value]); + + return ( + + {display.toLocaleString()}{suffix} + + ); +} diff --git a/src/components/auth/home-login-form.tsx b/src/components/auth/home-login-form.tsx index 89a16fc7..c9559d90 100644 --- a/src/components/auth/home-login-form.tsx +++ b/src/components/auth/home-login-form.tsx @@ -18,6 +18,7 @@ export function HomeLoginForm({ } = {}) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [code, setCode] = useState(""); const [needs2fa, setNeeds2fa] = useState(false); const [error, setError] = useState(null); @@ -87,7 +88,7 @@ export function HomeLoginForm({ placeholder="Your username" autoComplete="username" disabled={needs2fa} - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" + className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" style={{ backgroundColor: "var(--color-background)", color: "var(--color-text-readable)", @@ -100,31 +101,44 @@ export function HomeLoginForm({
- - setPassword(e.target.value)} - placeholder="Your password" - autoComplete="current-password" - disabled={needs2fa} - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" - style={{ - backgroundColor: "var(--color-background)", - color: "var(--color-text-readable)", - borderColor: needs2fa - ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" - : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", - }} - required - /> +
+ +
+
+ setPassword(e.target.value)} + placeholder="Your password" + autoComplete="current-password" + disabled={needs2fa} + className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + required + /> + +
{needs2fa ? ( @@ -169,7 +183,7 @@ export function HomeLoginForm({
-
+
setPassword(e.target.value)} placeholder={t("passwordPlaceholder")} autoComplete="current-password" disabled={needs2fa} - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" + className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" style={{ backgroundColor: "var(--color-background)", color: "var(--color-text-readable)", borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} /> +
{needs2fa ? (
@@ -120,7 +130,7 @@ export function LoginForm({ inputMode="numeric" autoComplete="one-time-code" autoFocus - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" style={{ backgroundColor: "var(--color-background)", color: "var(--color-text-readable)", @@ -144,7 +154,7 @@ export function LoginForm({ +
{password.length > 0 && (
@@ -225,20 +238,31 @@ export function RegisterForm({ > {t("confirmPassword")} - +
+ + +
@@ -323,7 +347,7 @@ export function RegisterForm({