diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 79a10f2..db5c9ce 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -3,12 +3,6 @@ import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from import { GetConfigurationValue, LocalizeText } from '../../api'; import { TurnstileWidget } from './TurnstileWidget'; -/** - * Looks up a localized string. Falls back to `fallback` when the key is - * missing (LocalizeText returns the key itself) or when the localization - * manager isn't ready yet (login runs very early). Parameters are - * %name%-substituted into the fallback so the UI stays correct pre-init. - */ const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string => { try @@ -16,7 +10,7 @@ const t = (key: string, fallback: string, params?: string[], replacements?: stri const value = LocalizeText(key, params ?? null, replacements ?? null); if(value && value !== key) return value; } - catch { /* localization manager not initialised yet */ } + catch {} if(!params || !replacements) return fallback; let out = fallback; @@ -328,7 +322,7 @@ export const LoginView: FC = ({ onAuthenticated }) => if(rememberMe && rememberToken) window.localStorage.setItem('nitro.remember.token', rememberToken); else window.localStorage.removeItem('nitro.remember.token'); } - catch { /* localStorage may be disabled in private mode */ } + catch {} clearLock(); onAuthenticated(ssoTicket); @@ -360,6 +354,8 @@ export const LoginView: FC = ({ onAuthenticated }) => } }, [ submitting, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + const newsUrl = GetConfigurationValue('login.news.endpoint', '/api/auth/news'); + const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); const imagingUrl = GetConfigurationValue('login.register.imaging.url', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); @@ -512,6 +508,8 @@ export const LoginView: FC = ({ onAuthenticated }) => { rightRepeat ?
: null } { right ?
: null } + +
{ t('nitro.login.firsttime.title', 'First time here?') }
@@ -1429,3 +1427,159 @@ const ForgotDialog: FC = props =>
); }; + +interface NewsItem +{ + id: number; + title: string; + body: string; + image: string | null; + linkText: string; + linkUrl: string; +} + +interface NewsWindowProps { newsUrl: string; } + +const NEWS_AUTO_ADVANCE_MS = 10000; + +const resolveNewsImage = (raw: string | null | undefined): string => +{ + const value = (raw ?? '').trim(); + if(!value) return ''; + if(/^https?:\/\//i.test(value)) return value; + if(value.startsWith('//')) return value; + if(value.startsWith('/') && !value.startsWith('//')) return value; + if(value.startsWith('data:')) + { + return /^data:image\/[a-z0-9.+-]+[,;]/i.test(value) ? value : ''; + } + + const stripped = value.replace(/\s+/g, ''); + if(!/^[A-Za-z0-9+/=]+$/.test(stripped)) return ''; + let mime = 'image/png'; + if(stripped.startsWith('/9j/')) mime = 'image/jpeg'; + else if(stripped.startsWith('R0lGOD')) mime = 'image/gif'; + else if(stripped.startsWith('UklGR')) mime = 'image/webp'; + else if(stripped.startsWith('PHN2Zy') || stripped.startsWith('PD94bWw')) mime = 'image/svg+xml'; + else if(stripped.startsWith('iVBORw0KGgo')) mime = 'image/png'; + return `data:${ mime };base64,${ stripped }`; +}; + +const resolveNewsLink = (raw: string | null | undefined): string => +{ + const value = (raw ?? '').trim(); + if(!value) return ''; + try + { + const url = new URL(value, window.location.href); + const proto = url.protocol.toLowerCase(); + if(proto !== 'http:' && proto !== 'https:') return ''; + return url.href; + } + catch { return ''; } +}; + +const NewsWindow: FC = ({ newsUrl }) => +{ + const [ items, setItems ] = useState(null); + const [ failed, setFailed ] = useState(false); + const [ index, setIndex ] = useState(0); + const [ autoTick, setAutoTick ] = useState(0); + + useEffect(() => + { + if(!newsUrl) { setFailed(true); return; } + let cancelled = false; + fetch(newsUrl, { credentials: 'omit' }) + .then(async r => + { + if(!r.ok) throw new Error('status ' + r.status); + return r.json(); + }) + .then((json: unknown) => + { + if(cancelled) return; + const list = Array.isArray((json as { news?: unknown })?.news) + ? (json as { news: NewsItem[] }).news + : []; + setItems(list); + }) + .catch(() => { if(!cancelled) setFailed(true); }); + return () => { cancelled = true; }; + }, [ newsUrl ]); + + useEffect(() => + { + if(!items || items.length < 2) return; + const id = window.setTimeout(() => + { + setIndex(i => (i + 1) % items.length); + }, NEWS_AUTO_ADVANCE_MS); + return () => window.clearTimeout(id); + }, [ items, index, autoTick ]); + + if(failed) return null; + if(!items || !items.length) return null; + + const current = items[Math.min(index, items.length - 1)]; + const hasMany = items.length > 1; + const bumpAuto = () => setAutoTick(t => t + 1); + const prev = () => { setIndex(i => (i - 1 + items.length) % items.length); bumpAuto(); }; + const next = () => { setIndex(i => (i + 1) % items.length); bumpAuto(); }; + + const safeLinkUrl = resolveNewsLink(current.linkUrl); + const safeImageSrc = resolveNewsImage(current.image); + const openLink = () => + { + if(!safeLinkUrl) return; + window.open(safeLinkUrl, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+
+ + + + + + +
+
+ { t('nitro.login.news.title', 'Hotel News') } +
+
+ { safeImageSrc && +
+ { { (e.currentTarget as HTMLImageElement).style.display = 'none'; } } + /> +
+ } +
{ current.title }
+ { current.body && +
{ current.body }
} + +
+ { current.linkText && safeLinkUrl + ? + : } + + { hasMany && +
+ + { index + 1 }/{ items.length } + +
+ } +
+
+
+
+
+ ); +}; diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index cd8ea96..df21654 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -586,3 +586,446 @@ line-height: 1.3; } +/* ─── Login News Window (Habbo flavour) ─── */ + +.nitro-login-view .login-news-stack { + position: absolute; + top: 25%; + left: 8vw; + transform: translateY(-50%); + display: flex; + flex-direction: column; + width: 388px; + z-index: 50; + pointer-events: auto; +} + +.nitro-login-view .news-card-wrapper { + position: relative; + animation: news-pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +.nitro-login-view .news-card-wrapper > .nitro-login-card.nitro-news-card { + position: relative; + overflow: visible; + border-width: 3px; + padding-top: 22px; + background: linear-gradient(180deg, #b9d4e3 0%, #a2bfd1 60%, #93b3c8 100%); + box-shadow: + inset 0 2px rgba(255, 255, 255, 0.5), + inset 0 -2px rgba(0, 0, 0, 0.12), + 0 6px 14px rgba(0, 0, 0, 0.35), + 0 0 0 4px rgba(63, 106, 133, 0.0); + animation: news-glow 3.2s ease-in-out infinite; +} + +/* Yellow Habbo-style ribbon title */ +.nitro-login-card.nitro-news-card .card-title.news-ribbon { + position: absolute; + top: -14px; + left: -10px; + right: -10px; + margin: 0; + padding: 6px 12px; + background: linear-gradient(180deg, #ffe27a 0%, #ffc742 50%, #f0a812 100%); + color: #5a3a00; + text-shadow: 0 1px rgba(255, 255, 255, 0.55); + border: 2px solid #8a5a00; + border-radius: 6px; + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.7), + inset 0 -2px rgba(0, 0, 0, 0.15), + 0 3px 0 rgba(0, 0, 0, 0.2); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.6px; + text-transform: uppercase; + text-align: center; + z-index: 2; +} + +/* Pennant tails on the ribbon */ +.nitro-login-card.nitro-news-card .card-title.news-ribbon::before, +.nitro-login-card.nitro-news-card .card-title.news-ribbon::after { + content: ""; + position: absolute; + bottom: -6px; + width: 12px; + height: 12px; + background: #c47800; + border: 2px solid #8a5a00; + z-index: -1; +} + +.nitro-login-card.nitro-news-card .card-title.news-ribbon::before { + left: -2px; + clip-path: polygon(0 0, 100% 0, 100% 100%); + transform: rotate(0deg); +} + +.nitro-login-card.nitro-news-card .card-title.news-ribbon::after { + right: -2px; + clip-path: polygon(0 0, 100% 0, 0 100%); +} + +.nitro-login-card.nitro-news-card .news-ribbon-text { + display: inline-block; + animation: news-ribbon-wobble 4s ease-in-out infinite; +} + +/* "NEW!" star badge */ +.nitro-login-view .news-new-badge { + position: absolute; + top: -28px; + right: -24px; + width: 78px; + height: 78px; + background: + radial-gradient(circle at 35% 30%, #fff7c2 0%, #ffd23a 45%, #d97c00 100%); + color: #5a1900; + font-weight: 900; + font-size: 11px; + letter-spacing: 0; + text-transform: uppercase; + text-shadow: 0 1px rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #8a3a00; + box-shadow: + inset 0 2px rgba(255, 255, 255, 0.55), + inset 0 -2px rgba(0, 0, 0, 0.2), + 0 3px 6px rgba(0, 0, 0, 0.35); + clip-path: polygon( + 50% 0%, 61% 35%, 98% 35%, 68% 57%, + 79% 91%, 50% 70%, 21% 91%, 32% 57%, + 2% 35%, 39% 35% + ); + z-index: 4; + animation: news-badge-spin 2.8s ease-in-out infinite; + pointer-events: none; +} + +.nitro-login-view .news-new-badge span { + transform: rotate(-10deg); + display: inline-block; + line-height: 1; + white-space: nowrap; +} + +/* Sparkles around the card */ +.nitro-login-view .news-sparkle { + position: absolute; + color: #fff5b0; + text-shadow: + 0 0 6px rgba(255, 220, 120, 0.9), + 0 0 12px rgba(255, 200, 60, 0.6); + pointer-events: none; + z-index: 3; + user-select: none; + font-weight: 700; +} + +.nitro-login-view .news-sparkle-1 { + top: -8px; + left: 18px; + font-size: 14px; + animation: news-sparkle 2.1s ease-in-out infinite; + animation-delay: 0s; +} + +.nitro-login-view .news-sparkle-2 { + top: 38%; + left: -12px; + font-size: 12px; + animation: news-sparkle 2.4s ease-in-out infinite; + animation-delay: 0.6s; +} + +.nitro-login-view .news-sparkle-3 { + bottom: -6px; + right: 36px; + font-size: 16px; + animation: news-sparkle 2.7s ease-in-out infinite; + animation-delay: 1.1s; +} + +/* Body */ +.nitro-login-card.nitro-news-card .card-body.news-body { + gap: 8px; + font-size: 12px; + color: #0a2e45; +} + +.nitro-login-card.nitro-news-card .news-image { + position: relative; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #3f6a85; + border-radius: 4px; + background: + repeating-linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 0 6px, + rgba(255, 255, 255, 0) 6px 12px + ), + linear-gradient(180deg, #cfe1ee 0%, #a8c5d6 100%); + overflow: hidden; + box-shadow: + inset 0 2px rgba(255, 255, 255, 0.6), + inset 0 -2px rgba(0, 0, 0, 0.15); + max-height: 150px; + transition: transform 0.25s ease; +} + +.nitro-login-card.nitro-news-card .news-image:hover { + transform: translateY(-1px) scale(1.01); +} + +.nitro-login-card.nitro-news-card .news-image::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 35%); +} + +.nitro-login-card.nitro-news-card .news-image img { + max-width: 100%; + max-height: 146px; + width: auto; + height: auto; + display: block; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + position: relative; + z-index: 1; +} + +.nitro-login-card.nitro-news-card .news-headline { + font-weight: 800; + font-size: 13px; + line-height: 1.25; + color: #0a2e45; + text-shadow: 0 1px rgba(255, 255, 255, 0.5); + letter-spacing: 0.2px; + border-bottom: 1px dashed rgba(63, 106, 133, 0.4); + padding-bottom: 4px; +} + +.nitro-login-card.nitro-news-card .news-text { + font-size: 11px; + line-height: 1.45; + color: #103e5d; + white-space: pre-line; + word-break: break-word; + max-height: 120px; + overflow-y: auto; + padding-right: 2px; +} + +.nitro-login-card.nitro-news-card .news-text::-webkit-scrollbar { + width: 6px; +} + +.nitro-login-card.nitro-news-card .news-text::-webkit-scrollbar-thumb { + background: rgba(63, 106, 133, 0.6); + border-radius: 3px; +} + +.nitro-login-card.nitro-news-card .news-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 4px; +} + +.nitro-login-card.nitro-news-card .news-link-button { + padding: 4px 14px; + font-size: 11px; + font-weight: 800; + background: linear-gradient(180deg, #ffe27a 0%, #ffc742 60%, #f0a812 100%); + color: #5a3a00; + border: 1px solid #8a5a00; + text-shadow: 0 1px rgba(255, 255, 255, 0.45); + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.7), + inset 0 -1px rgba(0, 0, 0, 0.15), + 0 2px 0 rgba(0, 0, 0, 0.2); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.nitro-login-card.nitro-news-card .news-link-button:hover { + background: linear-gradient(180deg, #fff0a8 0%, #ffd45c 60%, #f7b822 100%); + transform: translateY(-1px); + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.8), + inset 0 -1px rgba(0, 0, 0, 0.15), + 0 3px 0 rgba(0, 0, 0, 0.25); +} + +.nitro-login-card.nitro-news-card .news-link-button:active { + transform: translateY(1px); + box-shadow: + inset 0 1px rgba(0, 0, 0, 0.15), + 0 0 0 rgba(0, 0, 0, 0); +} + +.nitro-login-card.nitro-news-card .news-pager { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.nitro-login-card.nitro-news-card .news-pager .arrow-btn { + transition: transform 0.12s ease; +} + +.nitro-login-card.nitro-news-card .news-pager .arrow-btn:hover { + transform: scale(1.15); +} + +.nitro-login-card.nitro-news-card .news-counter { + font-size: 11px; + color: #134b6e; + font-weight: 700; + font-variant-numeric: tabular-nums; + min-width: 28px; + text-align: center; + text-shadow: 0 1px rgba(255, 255, 255, 0.4); +} + +@keyframes news-pop-in { + 0% { opacity: 0; transform: scale(0.85) translateY(8px); } + 60% { opacity: 1; transform: scale(1.04) translateY(0); } + 100% { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes news-glow { + 0%, 100% { box-shadow: + inset 0 2px rgba(255, 255, 255, 0.5), + inset 0 -2px rgba(0, 0, 0, 0.12), + 0 6px 14px rgba(0, 0, 0, 0.35), + 0 0 0 0 rgba(255, 210, 60, 0.0); } + 50% { box-shadow: + inset 0 2px rgba(255, 255, 255, 0.5), + inset 0 -2px rgba(0, 0, 0, 0.12), + 0 6px 14px rgba(0, 0, 0, 0.35), + 0 0 18px 4px rgba(255, 210, 60, 0.45); } +} + +@keyframes news-ribbon-wobble { + 0%, 100% { transform: rotate(0deg) translateY(0); } + 25% { transform: rotate(-1.2deg) translateY(-1px); } + 75% { transform: rotate(1.2deg) translateY(1px); } +} + +@keyframes news-badge-spin { + 0%, 100% { transform: rotate(-8deg) scale(1); } + 50% { transform: rotate(8deg) scale(1.08); } +} + +@keyframes news-sparkle { + 0%, 100% { opacity: 0.2; transform: scale(0.7) rotate(0deg); } + 50% { opacity: 1; transform: scale(1.2) rotate(20deg); } +} + +@media (prefers-reduced-motion: reduce) { + .nitro-login-view .news-card-wrapper, + .nitro-login-view .news-card-wrapper > .nitro-login-card.nitro-news-card, + .nitro-login-view .news-new-badge, + .nitro-login-view .news-sparkle, + .nitro-login-card.nitro-news-card .news-ribbon-text { + animation: none !important; + } +} + +@media (max-width: 900px) { + .nitro-login-view .login-news-stack { + display: none; + } +} + +/* ─── Cloud intro (plays once per session) ─── */ + +.login-intro-clouds { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; + overflow: hidden; + animation: cloud-overlay-fade 2.8s linear forwards; +} + +.intro-cloud-bank { + position: absolute; + left: -10%; + width: 120%; + height: 70%; + display: flex; + align-items: center; + justify-content: space-around; + will-change: transform; +} + +.intro-cloud-bank-top { + top: -70%; + animation: cloud-bank-top 2.8s cubic-bezier(0.65, 0, 0.35, 1) forwards; +} + +.intro-cloud-bank-bottom { + bottom: -70%; + animation: cloud-bank-bottom 2.8s cubic-bezier(0.65, 0, 0.35, 1) forwards; +} + +.intro-cloud-puff { + flex-shrink: 0; + background: + radial-gradient(ellipse at 45% 38%, #ffffff 0%, #fbfdff 35%, rgba(247, 251, 255, 0.85) 60%, rgba(255, 255, 255, 0) 78%); + filter: drop-shadow(0 8px 14px rgba(140, 175, 205, 0.35)); + border-radius: 50%; +} + +.intro-cloud-bank-top .intro-cloud-puff { + align-self: flex-end; +} + +.intro-cloud-bank-bottom .intro-cloud-puff { + align-self: flex-start; +} + +.intro-cloud-puff-1 { width: 360px; height: 320px; transform: translateY(-10px); } +.intro-cloud-puff-2 { width: 260px; height: 240px; transform: translateY(20px); } +.intro-cloud-puff-3 { width: 420px; height: 380px; transform: translateY(-30px); } +.intro-cloud-puff-4 { width: 300px; height: 280px; transform: translateY(15px); } +.intro-cloud-puff-5 { width: 340px; height: 300px; transform: translateY(-5px); } + +@keyframes cloud-bank-top { + 0% { transform: translateY(0); } + 35% { transform: translateY(105%); } + 55% { transform: translateY(105%); } + 100% { transform: translateY(-10%); } +} + +@keyframes cloud-bank-bottom { + 0% { transform: translateY(0); } + 35% { transform: translateY(-105%); } + 55% { transform: translateY(-105%); } + 100% { transform: translateY(10%); } +} + +@keyframes cloud-overlay-fade { + 0%, 88% { opacity: 1; } + 100% { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .login-intro-clouds, + .intro-cloud-bank-top, + .intro-cloud-bank-bottom { + animation-duration: 0.4s !important; + } +}