From 860cac1fd1226097117d6991028ec387ce2dacf8 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sun, 5 Jul 2026 22:18:47 +0200 Subject: [PATCH] Add nice compass loading animation --- android/app/build.gradle | 2 +- common/messages/de.json | 3 + common/messages/fr.json | 3 + .../home/home-loading-animation.tsx | 270 ++++++++++++++++++ web/pages/index.tsx | 28 +- web/pages/loading.tsx | 6 +- 6 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 web/components/home/home-loading-animation.tsx diff --git a/android/app/build.gradle b/android/app/build.gradle index caaa291c..51589afd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,7 +11,7 @@ android { applicationId "com.compassconnections.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 122 + versionCode 123 versionName "1.29.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { diff --git a/common/messages/de.json b/common/messages/de.json index cdad43a4..6b9210c3 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -1205,6 +1205,9 @@ "profiles.sort_differently": "Anders sortieren", "profiles.loading_dashboard": "Dashboard wird geladen…", "profiles.loading_dashboard_desc": "Der Server startet gerade, bitte warten.", + "profiles.loading_warming": "Der Kompass wird ausgerichtet…", + "profiles.loading_finding": "Wir suchen deine Leute…", + "profiles.loading_almost": "Fast geschafft…", "profiles.loading_slow": "Noch am Starten — gleich geht's los…", "profiles.title": "Personen", "profiles.try_keyword_search": "Schlüsselwortsuche versuchen", diff --git a/common/messages/fr.json b/common/messages/fr.json index 0585ace6..6ae4f132 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -1204,6 +1204,9 @@ "profiles.sort_differently": "Trier différemment", "profiles.loading_dashboard": "Chargement du tableau de bord…", "profiles.loading_dashboard_desc": "Le serveur démarre, patientez un instant.", + "profiles.loading_warming": "Calibrage de la boussole…", + "profiles.loading_finding": "Nous cherchons vos personnes…", + "profiles.loading_almost": "Presque terminé…", "profiles.loading_slow": "Démarrage en cours — encore quelques secondes…", "profiles.title": "Personnes", "profiles.try_keyword_search": "Essayer une recherche par mot-clé", diff --git a/web/components/home/home-loading-animation.tsx b/web/components/home/home-loading-animation.tsx new file mode 100644 index 00000000..5f69e100 --- /dev/null +++ b/web/components/home/home-loading-animation.tsx @@ -0,0 +1,270 @@ +import clsx from 'clsx' +import {useT} from 'web/lib/locale' + +// Full-bleed compass "app loading" splash shown on the home page while the +// user is still resolving. Ported from the Compass App Loading design: an +// igniting compass rose with a beating heart, radar sweep, sonar rings and a +// filling progress ring, over a warm radial backdrop. All colors are driven by +// the `--cl-*` custom properties below so the piece themes automatically: +// the design's dark palette in dark mode, a warm-cream variant in light mode. +export function HomeLoadingAnimation(props: {className?: string}) { + const {className} = props + const t = useT() + + const messages = [ + t('profiles.loading_dashboard_desc', 'The server is waking up, hang tight.'), + t('profiles.loading_warming', 'Warming up the compass…'), + t('profiles.loading_finding', 'Finding your people…'), + t('profiles.loading_almost', 'Almost there…'), + ] + + return ( +
+ +
+
+
+
+ +
+ {/* rotating radar sweep */} +
+ {/* expanding sonar rings */} +
+
+
+
+ {/* radar blips */} + + + + + + + + + {/* progress ring */} + + + + +
+ +
+
+ + {/* compass rose + heart */} + + + + + + + + + + + + + + + + +
+ +
+
{t('profiles.loading_dashboard', 'Loading dashboard…')}
+
+ {messages.map((msg, i) => ( +
+ {msg} +
+ ))} +
+
+ +
+
+
+ ) +} + +const styles = ` +.compass-load{ + /* fill the
content area rather than the whole viewport, so the + app chrome (left sidebar / bottom nav) stays visible around it. */ + position:relative;flex:1 1 auto;align-self:stretch;min-height:24rem; + /* light (default) — warm cream variant */ + --cl-bg1:#F7F4EF; --cl-bg2:#EDE8E0; + --cl-vignette:rgba(0,0,0,.10); + --cl-struct:30 26 20; /* rays / rings — dark ink on cream */ + --cl-heading:#1E1A14; + --cl-sub:#8C8070; + --cl-amber:193 127 62; /* primary-500 */ + --cl-gold:193 127 62; /* heart — deeper amber for contrast on cream */ + --cl-ignite:208 147 82; /* ignition highlights — primary-400 */ + --cl-blip:166 104 46; /* radar blips — primary-600 */ +} +.dark .compass-load{ + /* dark — faithful to the original design palette */ + --cl-bg1:#241E15; --cl-bg2:#1E1A14; + --cl-vignette:rgba(0,0,0,.42); + --cl-struct:247 244 239; + --cl-heading:#F7F4EF; + --cl-sub:#8C8070; + --cl-amber:193 127 62; + --cl-gold:222 180 121; + --cl-ignite:240 222 185; + --cl-blip:232 201 157; +} +.cl-screen{position:absolute;inset:0;z-index:0;overflow:hidden; + background:radial-gradient(130% 100% at 50% 44%,var(--cl-bg1) 0%,var(--cl-bg2) 58%); + display:flex;flex-direction:column;align-items:center;justify-content:center} +.cl-vignette{position:absolute;inset:0;pointer-events:none; + background:radial-gradient(120% 90% at 50% 46%,transparent 46%,var(--cl-vignette))} +.cl-stage{position:relative;width:220px;height:220px;transform:translateY(-8px)} +.cl-lay{position:absolute;inset:0;width:100%;height:100%;overflow:visible} +.cl-glow{position:absolute;inset:-46px;border-radius:50%; + background:radial-gradient(circle,rgb(var(--cl-amber)/.24),rgb(var(--cl-amber)/0) 62%); + opacity:0;animation:cl-glowBloom 1.2s ease .85s both,cl-c1Glow 1.8s ease-in-out 2.1s infinite} +.cl-loadfx{position:absolute;inset:0;opacity:0;animation:cl-fxReveal .6s ease 2.1s both} +.cl-radar{border-radius:50%;margin:6px; + background:conic-gradient(from 0deg,rgb(var(--cl-gold)/.16),rgb(var(--cl-gold)/.02) 55deg,transparent 90deg); + animation:cl-c1Radar 4s linear 2.1s infinite} +.cl-ring70{position:absolute;left:50%;top:50%;width:70px;height:70px;margin:-35px 0 0 -35px; + border-radius:50%;border:1.5px solid rgb(var(--cl-gold)/.55)} +.cl-sonar{animation:cl-c1Sonar 2.4s ease-out infinite} +.cl-blip{fill:rgb(var(--cl-blip));transform-box:fill-box;transform-origin:center; + animation:cl-c1Blip 2.4s ease-in-out infinite} +.cl-ringtrack{stroke:rgb(var(--cl-struct)/.07)} +.cl-prog{stroke:rgb(var(--cl-amber));stroke-dashoffset:1;animation:cl-progFill 10s ease-in-out 2.1s infinite} +.cl-dot{position:absolute;left:50%;top:50%;width:11px;height:11px;margin:-5.5px 0 0 -5.5px;border-radius:50%; + background:rgb(var(--cl-ignite));box-shadow:0 0 16px 3px rgb(var(--cl-ignite)/.9); + opacity:0;animation:cl-dotIgnite 1s ease .05s both} +.cl-shock{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;border-radius:50%; + border:1.6px solid rgb(var(--cl-ignite)/.7);opacity:0;animation:cl-shockwave .85s cubic-bezier(.2,.7,.3,1) 1s both} +.cl-rays{transform-box:fill-box;transform-origin:center;animation:cl-raysIn .95s cubic-bezier(.2,.8,.2,1) .3s both} +.cl-ray-major{fill:rgb(var(--cl-struct)/.42)} +.cl-ray-minor{fill:rgb(var(--cl-struct)/.28)} +.cl-innerring{stroke:rgb(var(--cl-struct)/.4);stroke-dashoffset:1;animation:cl-ringDraw .8s ease .2s both} +.cl-heart{transform-box:fill-box;transform-origin:center; + animation:cl-heartPop .7s cubic-bezier(.2,.8,.2,1) .95s both,cl-c1Beat 1.8s ease-in-out 2.1s infinite} +.cl-heart-path{fill:rgb(var(--cl-gold));filter:drop-shadow(0 0 6px rgb(var(--cl-gold)/.9))} +.cl-textblock{position:relative;z-index:3;display:flex;flex-direction:column;align-items:center;margin-top:8px} +.cl-head{margin:0;font-family:'Cormorant Garamond',Georgia,serif;font-weight:500;font-size:28px; + letter-spacing:.3px;color:var(--cl-heading);opacity:0;animation:cl-headIn .7s cubic-bezier(.2,.8,.2,1) 1.45s both} +.cl-subwrap{position:relative;height:22px;width:min(340px,90vw);margin-top:12px} +.cl-sub{position:absolute;inset:0;text-align:center;white-space:nowrap;font-size:14.5px;letter-spacing:.2px; + color:var(--cl-sub);opacity:0;animation:cl-msgFade 12s ease-in-out infinite} +.cl-flash{position:absolute;inset:0;pointer-events:none;z-index:4; + background:radial-gradient(circle at 50% 47%,rgb(var(--cl-ignite)/.5),rgb(var(--cl-ignite)/0) 55%); + opacity:0;animation:cl-flash .7s ease .95s both} +@keyframes cl-glowBloom{0%{opacity:0;transform:scale(.4)}55%{opacity:.95;transform:scale(1.16)}100%{opacity:.5;transform:scale(1)}} +@keyframes cl-c1Glow{0%{opacity:.5;transform:scale(1)}8%{opacity:.95;transform:scale(1.14)}40%{opacity:.5;transform:scale(1)}100%{opacity:.5;transform:scale(1)}} +@keyframes cl-dotIgnite{0%{transform:scale(0);opacity:0}28%{transform:scale(1);opacity:1}72%{opacity:.9}100%{transform:scale(2.6);opacity:0}} +@keyframes cl-ringDraw{0%{stroke-dashoffset:1;opacity:0}18%{opacity:1}100%{stroke-dashoffset:0;opacity:1}} +@keyframes cl-raysIn{0%{transform:scale(0) rotate(-75deg);opacity:0}70%{opacity:1}100%{transform:scale(1) rotate(0);opacity:1}} +@keyframes cl-heartPop{0%{transform:scale(0);opacity:0}55%{transform:scale(1.3);opacity:1}78%{transform:scale(.93)}100%{transform:scale(1)}} +@keyframes cl-shockwave{0%{transform:scale(.35);opacity:0}18%{opacity:.85}100%{transform:scale(3.3);opacity:0}} +@keyframes cl-flash{0%{opacity:0}42%{opacity:.24}100%{opacity:0}} +@keyframes cl-headIn{0%{opacity:0;transform:translateY(11px)}100%{opacity:1;transform:translateY(0)}} +@keyframes cl-fxReveal{0%{opacity:0}100%{opacity:1}} +@keyframes cl-msgFade{0%{opacity:0;transform:translateY(5px)}4%{opacity:1;transform:translateY(0)}21%{opacity:1;transform:translateY(0)}25%{opacity:0;transform:translateY(-5px)}100%{opacity:0;transform:translateY(-5px)}} +@keyframes cl-progFill{0%{stroke-dashoffset:1}86%{stroke-dashoffset:0}100%{stroke-dashoffset:0}} +@keyframes cl-c1Beat{0%{transform:scale(1)}8%{transform:scale(1.17)}16%{transform:scale(1.02)}26%{transform:scale(1.12)}40%{transform:scale(1)}100%{transform:scale(1)}} +@keyframes cl-c1Sonar{0%{transform:scale(.5);opacity:0}12%{opacity:.7}100%{transform:scale(3.4);opacity:0}} +@keyframes cl-c1Radar{to{transform:rotate(360deg)}} +@keyframes cl-c1Blip{0%,44%{opacity:.1;transform:scale(.5)}54%{opacity:1;transform:scale(1.4)}64%{opacity:.15;transform:scale(.9)}100%{opacity:.1;transform:scale(.5)}} +@media (prefers-reduced-motion: reduce){ + .compass-load *{animation-duration:.001s!important;animation-iteration-count:1!important; + animation-delay:0s!important;transition:none!important} + .cl-sub:not(:first-child){display:none} +} +` diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 38a54d74..00b7c84a 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,12 +1,10 @@ import {debug} from 'common/logger' -import {useEffect, useState} from 'react' import {LoggedOutHome} from 'web/components/home/home' +import {HomeLoadingAnimation} from 'web/components/home/home-loading-animation' import {Col} from 'web/components/layout/col' import {PageBase} from 'web/components/page-base' import {ProfilesHome} from 'web/components/profiles/profiles-home' -import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator' import {useUser} from 'web/hooks/use-user' -import {useT} from 'web/lib/locale' // To simulate downtime, you need the error to happen at runtime, not at build time. // That means the page must be server-rendered, not statically generated. @@ -16,31 +14,11 @@ import {useT} from 'web/lib/locale' export default function ProfilesPage() { const user = useUser() - const t = useT() - const [slowLoad, setSlowLoad] = useState(false) - - useEffect(() => { - if (user !== undefined) return - const timer = setTimeout(() => setSlowLoad(true), 5000) - return () => clearTimeout(timer) - }, [user]) if (user === undefined) { return ( - - - - -

- {t('profiles.loading_dashboard', 'Loading dashboard…')} -

-

- {slowLoad - ? t('profiles.loading_slow', 'Still starting up — almost there…') - : t('profiles.loading_dashboard_desc', 'The server is waking up, hang tight.')} -

- - + + ) } diff --git a/web/pages/loading.tsx b/web/pages/loading.tsx index 1ad418da..37539717 100644 --- a/web/pages/loading.tsx +++ b/web/pages/loading.tsx @@ -1,12 +1,12 @@ 'use client' +import {HomeLoadingAnimation} from 'web/components/home/home-loading-animation' import {PageBase} from 'web/components/page-base' -import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator' export default function Loading() { return ( - - + + ) }