Muhammad-Mehdi's picture
fix template previews
0822660
Raw
History Blame Contribute Delete
11.9 kB
import { useState, useEffect, useRef } from "react";
// ─── Scale wrapper (same pattern as other template previews)
const INTERNAL_W = 480;
const INTERNAL_H = 270;
function ScaledCanvas({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.5);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
const s = Math.max(el.offsetWidth / INTERNAL_W, el.offsetHeight / INTERNAL_H);
if (s > 0) setScale(s);
};
update();
const obs = new ResizeObserver(update);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={ref} style={{ width: "100%", aspectRatio: `${INTERNAL_W}/${INTERNAL_H}`, overflow: "hidden", position: "relative" }}>
<div style={{ width: INTERNAL_W, height: INTERNAL_H, transform: `scale(${scale})`, transformOrigin: "top left", position: "absolute" }}>
{children}
</div>
</div>
);
}
// ─── Design tokens
const ACCENT = "#00FF41";
const BLACK = "#000000";
const MUTED = "#00FF4166";
const FONT = "'Fira Code', 'Courier New', monospace";
// ─── Digital rain background (lightweight CSS version for preview)
function RainBackground() {
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", opacity: 0.25 }}>
{Array.from({ length: 20 }, (_, i) => (
<div
key={i}
style={{
position: "absolute",
left: `${i * 5}%`,
top: -20,
fontSize: 11,
fontFamily: FONT,
color: ACCENT,
lineHeight: 1.3,
opacity: 0.3 + Math.random() * 0.5,
animation: `matrixFall ${2 + Math.random() * 3}s linear ${Math.random() * 2}s infinite`,
whiteSpace: "pre",
}}
>
{"をむウ01\nγ‚«γ‚­γ‚―23\nγ‚΅γ‚·γ‚Ή45\nタチツ67\nγƒŠγƒ‹γƒŒ89\nハヒフAB\nγƒžγƒŸγƒ CD".split("\n").join("\n")}
</div>
))}
<style>{`
@keyframes matrixFall {
0% { transform: translateY(-100%); }
100% { transform: translateY(350px); }
}
`}</style>
</div>
);
}
function useSpring(
active: boolean,
options: { stiffness?: number; damping?: number; delay?: number } = {}
) {
const { stiffness = 200, damping = 18, delay = 0 } = options;
const [value, setValue] = useState(0);
useEffect(() => {
if (!active) { setValue(0); return; }
let start: number | null = null;
let raf: number;
let vel = 0;
let pos = 0;
function tick(t: number) {
if (!start) start = t + delay;
if (t < start) { raf = requestAnimationFrame(tick); return; }
const force = -stiffness * (pos - 1) - damping * vel;
vel += force * (1 / 60);
pos += vel * (1 / 60);
setValue(pos);
if (Math.abs(pos - 1) > 0.001 || Math.abs(vel) > 0.001) {
raf = requestAnimationFrame(tick);
} else {
setValue(1);
}
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [active, delay, stiffness, damping]);
return value;
}
function useCountUp(active: boolean, target: number, duration = 1200) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!active) { setVal(0); return; }
let start: number | null = null;
let raf: number;
function tick(t: number) {
if (!start) start = t;
const p = Math.min((t - start) / duration, 1);
const ease = 1 - Math.pow(1 - p, 3);
setVal(Math.round(ease * target));
if (p < 1) raf = requestAnimationFrame(tick);
else setVal(target);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [active, target, duration]);
return val;
}
// ─── Glitch text decode hook
function useGlitchText(active: boolean, text: string, delay = 0) {
const GLITCH = "をむウ0123!@#$%";
const [display, setDisplay] = useState("");
useEffect(() => {
if (!active) { setDisplay(""); return; }
let raf: number;
let frameCount = 0;
const chars = text.split("");
const framesPerChar = 3;
function tick() {
frameCount++;
const revealed = Math.floor((frameCount - delay / 16) / framesPerChar);
const result = chars.map((ch, i) => {
if (ch === " ") return " ";
if (i < revealed) return ch;
if (i < revealed + 4 && frameCount > delay / 16) {
return GLITCH[Math.floor(Math.random() * GLITCH.length)];
}
return " ";
}).join("");
setDisplay(result);
if (revealed < chars.length + 4) {
raf = requestAnimationFrame(tick);
} else {
setDisplay(text);
}
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [active, text, delay]);
return display;
}
// ─── Slide 1: Matrix Title (character decode)
function SlideMatrixTitle({ active }: { active: boolean }) {
const decoded = useGlitchText(active, "SYSTEM ONLINE", 200);
const subS = useSpring(active, { stiffness: 160, damping: 20, delay: 800 });
return (
<div style={{ width: "100%", height: "100%", background: BLACK, position: "relative", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center" }}>
<RainBackground />
<div style={{ textAlign: "center", padding: "0 48px", position: "relative", zIndex: 1 }}>
<div style={{
fontSize: 48, fontWeight: 700, color: ACCENT, letterSpacing: "-0.02em", lineHeight: 1.1,
fontFamily: FONT, textTransform: "uppercase" as const,
textShadow: `0 0 20px ${ACCENT}88, 0 0 40px ${ACCENT}44`,
}}>
{decoded || "\u00A0"}
</div>
<div style={{
marginTop: 12, fontSize: 11, fontWeight: 400, color: MUTED,
letterSpacing: "0.08em",
opacity: subS, transform: `translateY(${(1 - subS) * 12}px)`,
fontFamily: FONT,
}}>
Digital rain on a black void
</div>
</div>
</div>
);
}
// ─── Slide 2: Data Stream (terminal list)
const LIST_ITEMS = ["Lightning fast performance", "Zero config setup", "Built-in analytics"];
function SlideDataStream({ active }: { active: boolean }) {
const [current, setCurrent] = useState(-1);
useEffect(() => {
if (!active) { setCurrent(-1); return; }
let i = 0;
const timers: ReturnType<typeof setTimeout>[] = [];
const go = () => {
setCurrent(i);
i++;
if (i < LIST_ITEMS.length) timers.push(setTimeout(go, 500));
};
timers.push(setTimeout(go, 200));
return () => timers.forEach(clearTimeout);
}, [active]);
return (
<div style={{ width: "100%", height: "100%", background: BLACK, position: "relative", overflow: "hidden", display: "flex", alignItems: "center", padding: "0 40px" }}>
<RainBackground />
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 16, position: "relative", zIndex: 1 }}>
{LIST_ITEMS.map((item, i) => {
const shown = i <= current;
const dimmed = shown && i < current;
return (
<div key={i} style={{
display: "flex", alignItems: "center", gap: 14,
transform: shown ? "translateX(0)" : "translateX(60px)",
opacity: shown ? (dimmed ? 0.3 : 1) : 0,
transition: "transform 0.4s cubic-bezier(.17,.67,.22,1.2), opacity 0.35s ease",
}}>
<span style={{
fontSize: 18, fontWeight: 700, color: ACCENT, minWidth: 55,
fontFamily: FONT,
textShadow: `0 0 8px ${ACCENT}66`,
}}>
{">"} {String(i + 1).padStart(2, "0")}
</span>
<span style={{
fontSize: 20, fontWeight: 400, color: ACCENT,
fontFamily: FONT, letterSpacing: "0.01em",
}}>
{item}
</span>
</div>
);
})}
</div>
</div>
);
}
// ─── Slide 3: Cipher Metric (number decode)
function SlideCipherMetric({ active }: { active: boolean }) {
const num = useCountUp(active, 97, 1200);
const cardS = useSpring(active, { stiffness: 160, damping: 20, delay: 900 });
const glowPulse = active ? 0.7 + Math.sin(Date.now() * 0.003) * 0.3 : 0;
return (
<div style={{ width: "100%", height: "100%", background: BLACK, position: "relative", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center" }}>
<RainBackground />
<div style={{ textAlign: "center", position: "relative", zIndex: 1 }}>
<div style={{
fontSize: 100, fontWeight: 700, color: ACCENT, letterSpacing: "-0.03em",
lineHeight: 1, fontFamily: FONT,
textShadow: `0 0 ${20 * glowPulse}px ${ACCENT}88, 0 0 ${40 * glowPulse}px ${ACCENT}44`,
}}>
{active ? num : 0}
<span style={{ color: MUTED, fontSize: 50 }}>%</span>
</div>
<div style={{
marginTop: 16,
border: `1px solid ${ACCENT}44`,
padding: "10px 28px", display: "inline-block",
opacity: cardS, transform: `translateY(${(1 - cardS) * 10}px)`,
}}>
<div style={{
fontSize: 13, fontWeight: 700, color: ACCENT, letterSpacing: "0.12em",
textTransform: "uppercase" as const, fontFamily: FONT,
}}>
System Uptime
</div>
<div style={{ fontSize: 10, color: MUTED, marginTop: 3, fontFamily: FONT }}>
Based on 12,000+ requests
</div>
</div>
</div>
</div>
);
}
// ─── Slide indicator dots
function SlideDots({ total, current, onDotClick }: { total: number; current: number; onDotClick: (i: number) => void }) {
return (
<div style={{ display: "flex", gap: 5, position: "absolute", bottom: 10, right: 12, zIndex: 10 }}>
{Array.from({ length: total }, (_, i) => (
<button key={i} onClick={() => onDotClick(i)} style={{
width: i === current ? 16 : 6, height: 6, borderRadius: 3,
background: i === current ? ACCENT : `${ACCENT}44`,
border: "none", cursor: "pointer", padding: 0,
transition: "all 0.3s ease",
}} />
))}
</div>
);
}
// ─── Main
const SLIDES = [SlideMatrixTitle, SlideDataStream, SlideCipherMetric];
const SLIDE_DURATION = 3500;
export default function MatrixPreview({ thumbnailMode = false }: { thumbnailMode?: boolean } = {}) {
const [current, setCurrent] = useState(0);
const [active, setActive] = useState(false);
useEffect(() => {
// Side cards play the first slide's intro once and rest on its settled
// state (no slide cycling). Pinning to slide 0 also means the animation
// restarts from the top when the card returns to center.
setCurrent(0);
if (thumbnailMode) {
setActive(true);
return;
}
setActive(false);
const t = setTimeout(() => setActive(true), 200);
return () => clearTimeout(t);
}, [thumbnailMode]);
useEffect(() => {
if (thumbnailMode) return;
const id = setInterval(() => {
setActive(false);
setTimeout(() => {
setCurrent((c) => (c + 1) % SLIDES.length);
setActive(true);
}, 150);
}, SLIDE_DURATION);
return () => clearInterval(id);
}, [thumbnailMode]);
const handleDot = (i: number) => {
setActive(false);
setTimeout(() => { setCurrent(i); setActive(true); }, 100);
};
const SlideComp = SLIDES[current];
return (
<ScaledCanvas>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
<SlideComp active={active} />
<SlideDots total={SLIDES.length} current={current} onDotClick={handleDot} />
</div>
</ScaledCanvas>
);
}