Optimize homepage: defer below-fold mounts, lighter particles, faster loader.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
90abeac6f6
commit
8a0ca181e7
@ -30,6 +30,8 @@ const LOGS = [
|
|||||||
"新增饮食饮水注意事项",
|
"新增饮食饮水注意事项",
|
||||||
"修复生产环境 CSS 解析失败导致的页面异常",
|
"修复生产环境 CSS 解析失败导致的页面异常",
|
||||||
"部署改为原生 systemd(非 Docker 优先),增量构建更快",
|
"部署改为原生 systemd(非 Docker 优先),增量构建更快",
|
||||||
|
"首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
|
||||||
|
"专注时钟增加累计专注块统计",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1169,6 +1169,49 @@ img { max-width: 100%; display: block; }
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deferred-skeleton {
|
||||||
|
height: 120px;
|
||||||
|
margin: 24px auto;
|
||||||
|
max-width: 1200px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: linear-gradient(90deg, rgba(255,255,255,0.03), rgba(255,255,255,0.07), rgba(255,255,255,0.03));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: deferredShimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deferredShimmer {
|
||||||
|
0% { background-position: 100% 0; }
|
||||||
|
100% { background-position: -100% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-streak {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: -4px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-streak b {
|
||||||
|
color: var(--accent-3);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.deferred-skeleton { animation: none; }
|
||||||
|
.loader-ring,
|
||||||
|
.loader-progress { animation: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.hero-top {
|
.hero-top {
|
||||||
|
|||||||
@ -3,18 +3,32 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const PRESETS = [25, 45, 60];
|
const PRESETS = [25, 45, 60];
|
||||||
|
const STORAGE_KEY = "nomadro-focus-streak";
|
||||||
|
|
||||||
export default function FocusTimer() {
|
export default function FocusTimer() {
|
||||||
const [minutes, setMinutes] = useState(25);
|
const [minutes, setMinutes] = useState(25);
|
||||||
const [secondsLeft, setSecondsLeft] = useState(25 * 60);
|
const [secondsLeft, setSecondsLeft] = useState(25 * 60);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
const [sessions, setSessions] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem(STORAGE_KEY) || "0");
|
||||||
|
if (!Number.isNaN(n)) setSessions(n);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
if (secondsLeft <= 0) {
|
if (secondsLeft <= 0) {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
setDone(true);
|
setDone(true);
|
||||||
|
setSessions((prev) => {
|
||||||
|
const next = prev + 1;
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* ignore */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||||
@ -55,6 +69,10 @@ export default function FocusTimer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="focus-streak">
|
||||||
|
今日累计专注块 <b>{sessions}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="focus-presets">
|
<div className="focus-presets">
|
||||||
{PRESETS.map((m) => (
|
{PRESETS.map((m) => (
|
||||||
<button key={m} className={`filter-btn${minutes === m && !running ? " active" : ""}`} onClick={() => pick(m)} disabled={running}>
|
<button key={m} className={`filter-btn${minutes === m && !running ? " active" : ""}`} onClick={() => pick(m)} disabled={running}>
|
||||||
@ -73,7 +91,7 @@ export default function FocusTimer() {
|
|||||||
)}
|
)}
|
||||||
<button className="btn btn-ghost" onClick={reset}>重置</button>
|
<button className="btn btn-ghost" onClick={reset}>重置</button>
|
||||||
</div>
|
</div>
|
||||||
{done && <p className="focus-done">🎉 完成一个专注块,起来走走吧</p>}
|
{done && <p className="focus-done">完成一个专注块,起来走走吧</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import BackToTop from "./BackToTop";
|
|||||||
import TripPlanner from "./TripPlanner";
|
import TripPlanner from "./TripPlanner";
|
||||||
import Calculator from "./Calculator";
|
import Calculator from "./Calculator";
|
||||||
import SpinGlobe from "./SpinGlobe";
|
import SpinGlobe from "./SpinGlobe";
|
||||||
|
import WhenVisible from "./WhenVisible";
|
||||||
|
|
||||||
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
||||||
dynamic(loader, { ssr: false, loading: () => null });
|
dynamic(loader, { ssr: false, loading: () => null });
|
||||||
@ -167,49 +168,49 @@ export default function HomeClient(props: Props) {
|
|||||||
<SpinGlobe destinations={props.destinations} />
|
<SpinGlobe destinations={props.destinations} />
|
||||||
<DailyQuote />
|
<DailyQuote />
|
||||||
<ToolsStrip />
|
<ToolsStrip />
|
||||||
<TimezoneBoard destinations={props.destinations} />
|
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
|
||||||
<JetLagEstimator destinations={props.destinations} />
|
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
|
||||||
<TripPlanner destinations={props.destinations} />
|
<TripPlanner destinations={props.destinations} />
|
||||||
<Calculator destinations={props.destinations} />
|
<Calculator destinations={props.destinations} />
|
||||||
<CostCompare destinations={props.destinations} />
|
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
|
||||||
<SavingsGoal destinations={props.destinations} />
|
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
|
||||||
<RunwayCalculator destinations={props.destinations} />
|
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
|
||||||
<FirstMonthCost destinations={props.destinations} />
|
<WhenVisible><FirstMonthCost destinations={props.destinations} /></WhenVisible>
|
||||||
<ExpenseTracker />
|
<WhenVisible><ExpenseTracker /></WhenVisible>
|
||||||
<TaxDayCounter />
|
<WhenVisible><TaxDayCounter /></WhenVisible>
|
||||||
<FlightCost destinations={props.destinations} />
|
<WhenVisible><FlightCost destinations={props.destinations} /></WhenVisible>
|
||||||
<CurrencyConverter />
|
<WhenVisible><CurrencyConverter /></WhenVisible>
|
||||||
<MeetingWindow destinations={props.destinations} />
|
<WhenVisible><MeetingWindow destinations={props.destinations} /></WhenVisible>
|
||||||
<DaylightWorkHours destinations={props.destinations} />
|
<WhenVisible><DaylightWorkHours destinations={props.destinations} /></WhenVisible>
|
||||||
<WifiWorkScore destinations={props.destinations} />
|
<WhenVisible><WifiWorkScore destinations={props.destinations} /></WhenVisible>
|
||||||
<WorkSpotCost destinations={props.destinations} />
|
<WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
|
||||||
<DataBudgetPlanner />
|
<WhenVisible><DataBudgetPlanner /></WhenVisible>
|
||||||
<FocusTimer />
|
<WhenVisible><FocusTimer /></WhenVisible>
|
||||||
<NomadScore />
|
<WhenVisible><NomadScore /></WhenVisible>
|
||||||
<Lifestyle />
|
<WhenVisible><Lifestyle /></WhenVisible>
|
||||||
<Charts {...props.charts} />
|
<WhenVisible><Charts {...props.charts} /></WhenVisible>
|
||||||
<VisaSection visas={props.visas} />
|
<WhenVisible><VisaSection visas={props.visas} /></WhenVisible>
|
||||||
<VisaStayCountdown destinations={props.destinations} />
|
<WhenVisible><VisaStayCountdown destinations={props.destinations} /></WhenVisible>
|
||||||
<InsuranceGuide />
|
<WhenVisible><InsuranceGuide /></WhenVisible>
|
||||||
<EmergencyContacts destinations={props.destinations} />
|
<WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
|
||||||
<AtmCashGuide destinations={props.destinations} />
|
<WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<FoodWaterTips destinations={props.destinations} />
|
<WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
|
||||||
<CoworkingGuide />
|
<WhenVisible><CoworkingGuide /></WhenVisible>
|
||||||
<HousingGuide destinations={props.destinations} />
|
<WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<SimGuide destinations={props.destinations} />
|
<WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<PowerPlugGuide destinations={props.destinations} />
|
<WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<CafeGuide destinations={props.destinations} />
|
<WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<SeasonGuide destinations={props.destinations} />
|
<WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<PackingChecklist />
|
<WhenVisible><PackingChecklist /></WhenVisible>
|
||||||
<BagWeightEstimator />
|
<WhenVisible><BagWeightEstimator /></WhenVisible>
|
||||||
<ArrivalChecklist destinations={props.destinations} />
|
<WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
|
||||||
<Phrasebook destinations={props.destinations} />
|
<WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
|
||||||
<CityNotes destinations={props.destinations} />
|
<WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
|
||||||
<NomadEvents />
|
<WhenVisible><NomadEvents /></WhenVisible>
|
||||||
<Tools tools={props.tools} />
|
<WhenVisible><Tools tools={props.tools} /></WhenVisible>
|
||||||
<BlogSection posts={props.blog} />
|
<WhenVisible><BlogSection posts={props.blog} /></WhenVisible>
|
||||||
<FAQSection faqs={props.faqs} />
|
<WhenVisible><FAQSection faqs={props.faqs} /></WhenVisible>
|
||||||
<Community testimonials={props.testimonials} />
|
<WhenVisible><Community testimonials={props.testimonials} /></WhenVisible>
|
||||||
<Footer />
|
<Footer />
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<SectionNav />
|
<SectionNav />
|
||||||
|
|||||||
@ -4,22 +4,35 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
export default function Loader() {
|
export default function Loader() {
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
const [gone, setGone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hide = () => setDone(true);
|
const hide = () => setDone(true);
|
||||||
const onLoad = () => setTimeout(hide, 900);
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const delay = reduced ? 0 : 380;
|
||||||
|
|
||||||
if (document.readyState === "complete") {
|
if (document.readyState === "complete") {
|
||||||
const t = setTimeout(hide, 900);
|
const t = setTimeout(hide, delay);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLoad = () => setTimeout(hide, delay);
|
||||||
window.addEventListener("load", onLoad);
|
window.addEventListener("load", onLoad);
|
||||||
const fallback = setTimeout(hide, 2200);
|
const fallback = setTimeout(hide, reduced ? 200 : 1200);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("load", onLoad);
|
window.removeEventListener("load", onLoad);
|
||||||
clearTimeout(fallback);
|
clearTimeout(fallback);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!done) return;
|
||||||
|
const t = setTimeout(() => setGone(true), 500);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [done]);
|
||||||
|
|
||||||
|
if (gone) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`loader${done ? " loader-done" : ""}`} id="loader" aria-hidden={done}>
|
<div className={`loader${done ? " loader-done" : ""}`} id="loader" aria-hidden={done}>
|
||||||
<div className="loader-inner">
|
<div className="loader-inner">
|
||||||
|
|||||||
@ -11,7 +11,14 @@ export default function ParticleCanvas() {
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let animId: number;
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduced) {
|
||||||
|
canvas.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animId = 0;
|
||||||
|
let running = true;
|
||||||
let particles: {
|
let particles: {
|
||||||
x: number; y: number; size: number;
|
x: number; y: number; size: number;
|
||||||
speedX: number; speedY: number; opacity: number; hue: number;
|
speedX: number; speedY: number; opacity: number; hue: number;
|
||||||
@ -23,20 +30,27 @@ export default function ParticleCanvas() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const create = () => {
|
const create = () => {
|
||||||
const count = Math.min(Math.floor(window.innerWidth / 12), 120);
|
const count = Math.min(Math.floor(window.innerWidth / 28), 48);
|
||||||
particles = Array.from({ length: count }, () => ({
|
particles = Array.from({ length: count }, () => ({
|
||||||
x: Math.random() * canvas.width,
|
x: Math.random() * canvas.width,
|
||||||
y: Math.random() * canvas.height,
|
y: Math.random() * canvas.height,
|
||||||
size: Math.random() * 2 + 0.5,
|
size: Math.random() * 2 + 0.5,
|
||||||
speedX: (Math.random() - 0.5) * 0.3,
|
speedX: (Math.random() - 0.5) * 0.25,
|
||||||
speedY: (Math.random() - 0.5) * 0.3,
|
speedY: (Math.random() - 0.5) * 0.25,
|
||||||
opacity: Math.random() * 0.5 + 0.1,
|
opacity: Math.random() * 0.4 + 0.1,
|
||||||
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
resize();
|
||||||
|
create();
|
||||||
|
};
|
||||||
|
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
|
if (!running) return;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const linkDist = window.innerWidth < 768 ? 0 : 90;
|
||||||
particles.forEach((p, i) => {
|
particles.forEach((p, i) => {
|
||||||
p.x += p.speedX;
|
p.x += p.speedX;
|
||||||
p.y += p.speedY;
|
p.y += p.speedY;
|
||||||
@ -50,28 +64,42 @@ export default function ParticleCanvas() {
|
|||||||
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
particles.slice(i + 1).forEach((p2) => {
|
if (linkDist > 0) {
|
||||||
const dx = p.x - p2.x;
|
for (let j = i + 1; j < particles.length; j++) {
|
||||||
const dy = p.y - p2.y;
|
const p2 = particles[j];
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
const dx = p.x - p2.x;
|
||||||
if (dist < 120) {
|
const dy = p.y - p2.y;
|
||||||
ctx.beginPath();
|
const dist = Math.hypot(dx, dy);
|
||||||
ctx.moveTo(p.x, p.y);
|
if (dist < linkDist) {
|
||||||
ctx.lineTo(p2.x, p2.y);
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
|
ctx.moveTo(p.x, p.y);
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineTo(p2.x, p2.y);
|
||||||
ctx.stroke();
|
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.06 * (1 - dist / linkDist)})`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
animId = requestAnimationFrame(draw);
|
animId = requestAnimationFrame(draw);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onVis = () => {
|
||||||
|
running = document.visibilityState === "visible";
|
||||||
|
if (running) draw();
|
||||||
|
else cancelAnimationFrame(animId);
|
||||||
|
};
|
||||||
|
|
||||||
resize();
|
resize();
|
||||||
create();
|
create();
|
||||||
draw();
|
draw();
|
||||||
window.addEventListener("resize", () => { resize(); create(); });
|
window.addEventListener("resize", onResize);
|
||||||
return () => { cancelAnimationFrame(animId); };
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <canvas id="particle-canvas" ref={canvasRef} aria-hidden="true" />;
|
return <canvas id="particle-canvas" ref={canvasRef} aria-hidden="true" />;
|
||||||
|
|||||||
43
frontend/src/components/WhenVisible.tsx
Normal file
43
frontend/src/components/WhenVisible.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Mount children only when near the viewport — defers heavy below-fold chunks. */
|
||||||
|
export default function WhenVisible({
|
||||||
|
children,
|
||||||
|
rootMargin = "640px 0px",
|
||||||
|
minHeight = 160,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
rootMargin?: string;
|
||||||
|
minHeight?: number;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || show) return;
|
||||||
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
|
setShow(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setShow(true);
|
||||||
|
io.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin, threshold: 0.01 }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [rootMargin, show]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}>
|
||||||
|
{show ? children : <div className="deferred-skeleton" aria-hidden />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user