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 解析失败导致的页面异常",
|
||||
"部署改为原生 systemd(非 Docker 优先),增量构建更快",
|
||||
"首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
|
||||
"专注时钟增加累计专注块统计",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1169,6 +1169,49 @@ img { max-width: 100%; display: block; }
|
||||
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 ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.hero-top {
|
||||
|
||||
@ -3,18 +3,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PRESETS = [25, 45, 60];
|
||||
const STORAGE_KEY = "nomadro-focus-streak";
|
||||
|
||||
export default function FocusTimer() {
|
||||
const [minutes, setMinutes] = useState(25);
|
||||
const [secondsLeft, setSecondsLeft] = useState(25 * 60);
|
||||
const [running, setRunning] = 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(() => {
|
||||
if (!running) return;
|
||||
if (secondsLeft <= 0) {
|
||||
setRunning(false);
|
||||
setDone(true);
|
||||
setSessions((prev) => {
|
||||
const next = prev + 1;
|
||||
try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* ignore */ }
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
@ -55,6 +69,10 @@ export default function FocusTimer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="focus-streak">
|
||||
今日累计专注块 <b>{sessions}</b>
|
||||
</div>
|
||||
|
||||
<div className="focus-presets">
|
||||
{PRESETS.map((m) => (
|
||||
<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>
|
||||
</div>
|
||||
{done && <p className="focus-done">🎉 完成一个专注块,起来走走吧</p>}
|
||||
{done && <p className="focus-done">完成一个专注块,起来走走吧</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -28,6 +28,7 @@ import BackToTop from "./BackToTop";
|
||||
import TripPlanner from "./TripPlanner";
|
||||
import Calculator from "./Calculator";
|
||||
import SpinGlobe from "./SpinGlobe";
|
||||
import WhenVisible from "./WhenVisible";
|
||||
|
||||
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
||||
dynamic(loader, { ssr: false, loading: () => null });
|
||||
@ -167,49 +168,49 @@ export default function HomeClient(props: Props) {
|
||||
<SpinGlobe destinations={props.destinations} />
|
||||
<DailyQuote />
|
||||
<ToolsStrip />
|
||||
<TimezoneBoard destinations={props.destinations} />
|
||||
<JetLagEstimator destinations={props.destinations} />
|
||||
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
|
||||
<TripPlanner destinations={props.destinations} />
|
||||
<Calculator destinations={props.destinations} />
|
||||
<CostCompare destinations={props.destinations} />
|
||||
<SavingsGoal destinations={props.destinations} />
|
||||
<RunwayCalculator destinations={props.destinations} />
|
||||
<FirstMonthCost destinations={props.destinations} />
|
||||
<ExpenseTracker />
|
||||
<TaxDayCounter />
|
||||
<FlightCost destinations={props.destinations} />
|
||||
<CurrencyConverter />
|
||||
<MeetingWindow destinations={props.destinations} />
|
||||
<DaylightWorkHours destinations={props.destinations} />
|
||||
<WifiWorkScore destinations={props.destinations} />
|
||||
<WorkSpotCost destinations={props.destinations} />
|
||||
<DataBudgetPlanner />
|
||||
<FocusTimer />
|
||||
<NomadScore />
|
||||
<Lifestyle />
|
||||
<Charts {...props.charts} />
|
||||
<VisaSection visas={props.visas} />
|
||||
<VisaStayCountdown destinations={props.destinations} />
|
||||
<InsuranceGuide />
|
||||
<EmergencyContacts destinations={props.destinations} />
|
||||
<AtmCashGuide destinations={props.destinations} />
|
||||
<FoodWaterTips destinations={props.destinations} />
|
||||
<CoworkingGuide />
|
||||
<HousingGuide destinations={props.destinations} />
|
||||
<SimGuide destinations={props.destinations} />
|
||||
<PowerPlugGuide destinations={props.destinations} />
|
||||
<CafeGuide destinations={props.destinations} />
|
||||
<SeasonGuide destinations={props.destinations} />
|
||||
<PackingChecklist />
|
||||
<BagWeightEstimator />
|
||||
<ArrivalChecklist destinations={props.destinations} />
|
||||
<Phrasebook destinations={props.destinations} />
|
||||
<CityNotes destinations={props.destinations} />
|
||||
<NomadEvents />
|
||||
<Tools tools={props.tools} />
|
||||
<BlogSection posts={props.blog} />
|
||||
<FAQSection faqs={props.faqs} />
|
||||
<Community testimonials={props.testimonials} />
|
||||
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><FirstMonthCost destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><ExpenseTracker /></WhenVisible>
|
||||
<WhenVisible><TaxDayCounter /></WhenVisible>
|
||||
<WhenVisible><FlightCost destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><CurrencyConverter /></WhenVisible>
|
||||
<WhenVisible><MeetingWindow destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><DaylightWorkHours destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><WifiWorkScore destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><DataBudgetPlanner /></WhenVisible>
|
||||
<WhenVisible><FocusTimer /></WhenVisible>
|
||||
<WhenVisible><NomadScore /></WhenVisible>
|
||||
<WhenVisible><Lifestyle /></WhenVisible>
|
||||
<WhenVisible><Charts {...props.charts} /></WhenVisible>
|
||||
<WhenVisible><VisaSection visas={props.visas} /></WhenVisible>
|
||||
<WhenVisible><VisaStayCountdown destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><InsuranceGuide /></WhenVisible>
|
||||
<WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><CoworkingGuide /></WhenVisible>
|
||||
<WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><PackingChecklist /></WhenVisible>
|
||||
<WhenVisible><BagWeightEstimator /></WhenVisible>
|
||||
<WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
|
||||
<WhenVisible><NomadEvents /></WhenVisible>
|
||||
<WhenVisible><Tools tools={props.tools} /></WhenVisible>
|
||||
<WhenVisible><BlogSection posts={props.blog} /></WhenVisible>
|
||||
<WhenVisible><FAQSection faqs={props.faqs} /></WhenVisible>
|
||||
<WhenVisible><Community testimonials={props.testimonials} /></WhenVisible>
|
||||
<Footer />
|
||||
<BackToTop />
|
||||
<SectionNav />
|
||||
|
||||
@ -4,22 +4,35 @@ import { useEffect, useState } from "react";
|
||||
|
||||
export default function Loader() {
|
||||
const [done, setDone] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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") {
|
||||
const t = setTimeout(hide, 900);
|
||||
const t = setTimeout(hide, delay);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
|
||||
const onLoad = () => setTimeout(hide, delay);
|
||||
window.addEventListener("load", onLoad);
|
||||
const fallback = setTimeout(hide, 2200);
|
||||
const fallback = setTimeout(hide, reduced ? 200 : 1200);
|
||||
return () => {
|
||||
window.removeEventListener("load", onLoad);
|
||||
clearTimeout(fallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const t = setTimeout(() => setGone(true), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [done]);
|
||||
|
||||
if (gone) return null;
|
||||
|
||||
return (
|
||||
<div className={`loader${done ? " loader-done" : ""}`} id="loader" aria-hidden={done}>
|
||||
<div className="loader-inner">
|
||||
|
||||
@ -11,7 +11,14 @@ export default function ParticleCanvas() {
|
||||
const ctx = canvas.getContext("2d");
|
||||
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: {
|
||||
x: number; y: number; size: number;
|
||||
speedX: number; speedY: number; opacity: number; hue: number;
|
||||
@ -23,20 +30,27 @@ export default function ParticleCanvas() {
|
||||
};
|
||||
|
||||
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 }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: Math.random() * 2 + 0.5,
|
||||
speedX: (Math.random() - 0.5) * 0.3,
|
||||
speedY: (Math.random() - 0.5) * 0.3,
|
||||
opacity: Math.random() * 0.5 + 0.1,
|
||||
speedX: (Math.random() - 0.5) * 0.25,
|
||||
speedY: (Math.random() - 0.5) * 0.25,
|
||||
opacity: Math.random() * 0.4 + 0.1,
|
||||
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
||||
}));
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
resize();
|
||||
create();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
if (!running) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const linkDist = window.innerWidth < 768 ? 0 : 90;
|
||||
particles.forEach((p, i) => {
|
||||
p.x += p.speedX;
|
||||
p.y += p.speedY;
|
||||
@ -50,28 +64,42 @@ export default function ParticleCanvas() {
|
||||
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
||||
ctx.fill();
|
||||
|
||||
particles.slice(i + 1).forEach((p2) => {
|
||||
if (linkDist > 0) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const p2 = particles[j];
|
||||
const dx = p.x - p2.x;
|
||||
const dy = p.y - p2.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 120) {
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < linkDist) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p2.x, p2.y);
|
||||
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
|
||||
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.06 * (1 - dist / linkDist)})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
animId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const onVis = () => {
|
||||
running = document.visibilityState === "visible";
|
||||
if (running) draw();
|
||||
else cancelAnimationFrame(animId);
|
||||
};
|
||||
|
||||
resize();
|
||||
create();
|
||||
draw();
|
||||
window.addEventListener("resize", () => { resize(); create(); });
|
||||
return () => { cancelAnimationFrame(animId); };
|
||||
window.addEventListener("resize", onResize);
|
||||
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" />;
|
||||
|
||||
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