build a template website

This commit is contained in:
rainyy21
2026-10-05 19:40:24 -04:00
commit c86f93f4ca
36 changed files with 2581 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from 'react';
import data from '../links.json';
import { REDUCED, rnd } from './lib/env.js';
import { useRain } from './lib/rain.js';
import { trackVisit } from './tracker.js';
import Mascot from './components/Mascot.jsx';
import LinkButton from './components/LinkButton.jsx';
const LEVEL = 0.5; // fixed rain intensity
export default function App() {
const { profile, featured, links } = data;
// const taglines = profile.taglines;
const [rain, setRain] = useState(!REDUCED);
const [flash, setFlash] = useState(0);
// const [tg, setTg] = useState(0);
const canvasRef = useRef(null);
const levelRef = useRef(LEVEL);
useRain(canvasRef, rain, levelRef);
useEffect(() => { trackVisit(); }, []);
// useEffect(() => { const t = setInterval(() => setTg((n) => n + 1), 4500); return () => clearInterval(t); }, []);
useEffect(() => {
if (!rain || REDUCED) return;
let t;
const next = () => {
t = setTimeout(() => { if (!document.hidden) setFlash((n) => n + 1); next(); }, (22 - 16 * LEVEL) * 1000 * rnd(0.5, 1.5));
};
next();
return () => clearTimeout(t);
}, [rain]);
return (
<div>
<canvas className="rain" ref={canvasRef} aria-hidden="true" />
<div key={flash} className={'flash' + (flash ? ' on' : '')} aria-hidden="true" />
<main>
<Mascot />
<h1>{profile.handle}</h1>
{/* <p className="tag" key={tg % taglines.length}>{taglines[tg % taglines.length]}</p> */}
<nav className="links" aria-label="Links">
{/* <LinkButton item={featured} featured /> */}
{links.map((l) => <LinkButton key={l.id} item={l} />)}
</nav>
<footer>
<button className="tog" aria-pressed={rain} onClick={() => setRain(!rain)}>{rain ? 'Rain on' : 'Rain off'}</button>
</footer>
</main>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import { REDUCED, rnd } from '../lib/env.js';
import { ripple } from '../lib/effects.js';
import { trackClick } from '../tracker.js';
export default function LinkButton({ item, featured }) {
const fog = useRef(null);
const dirty = useRef(0);
const deco = useRef(null);
if (!deco.current) {
deco.current = {
beads: [rnd(12, 40), rnd(55, 88)].map((l, i) => ({ l, d: rnd(0, 6) + i })),
drips: [rnd(10, 40), rnd(55, 90)].map((l, i) => ({ l, d: rnd(0, 5) + i * 2 })),
};
}
const paint = (a) => {
const c = fog.current;
if (!c) return;
const x = c.getContext('2d');
x.globalCompositeOperation = 'source-over';
x.fillStyle = `rgba(175,200,228,${a})`;
x.fillRect(0, 0, c.width, c.height);
};
useEffect(() => {
const c = fog.current;
if (!c) return;
const r = c.parentNode.getBoundingClientRect();
c.width = r.width;
c.height = r.height;
paint(1);
const t = setInterval(() => { if (dirty.current > 0) { dirty.current--; paint(0.1); } }, 250);
return () => clearInterval(t);
}, []);
const wipe = (e) => {
const c = fog.current;
if (!c) return;
const r = c.getBoundingClientRect();
const x = c.getContext('2d');
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(e.clientX - r.left, e.clientY - r.top, 24, 0, 6.3);
x.fill();
dirty.current = 25;
};
const onClick = () => {
if (navigator.vibrate) navigator.vibrate(10);
trackClick(item.id);
};
const external = item.href.startsWith('http');
return (
<div className="wrap">
<a
className={'btn' + (featured ? ' feat' : '')}
href={item.href}
onPointerDown={(e) => { ripple(e); wipe(e); }}
onPointerMove={wipe}
onClick={onClick}
target={external ? '_blank' : undefined}
rel="noopener"
aria-label={item.label + (item.sub ? ', ' + item.sub : '')}
>
{!REDUCED && <canvas className="fog" ref={fog} aria-hidden="true" />}
{!REDUCED && deco.current.beads.map((b, i) => (
<span key={i} className="bead" style={{ left: b.l + '%', animationDelay: -b.d + 's' }} />
))}
<span className="ico" aria-hidden="true">
{item.icon.startsWith('/') ? (
<img src={item.icon} alt="" className="ico-svg" />
) : (
item.icon
)}
</span>
<span>
{item.label}
{item.sub && <span className="sub">{item.sub}</span>}
</span>
</a>
{!REDUCED && deco.current.drips.map((d, i) => (
<span key={i} className="drip" style={{ left: d.l + '%', animationDelay: -d.d + 's' }} />
))}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Mascot() {
return (
<div className="mascot" role="img" aria-label="Rain umbrella mascot">
<img src="/rain_ubrella.png" alt="Rain umbrella mascot" width="132" height="153" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { REDUCED, rnd } from './env.js';
export function ripple(e) {
const el = e.currentTarget, r = el.getBoundingClientRect(), s = Math.max(r.width, r.height) * 2, n = document.createElement("span");
n.className = "ripple"; n.style.cssText = `width:${s}px;height:${s}px;left:${e.clientX - r.left - s / 2}px;top:${e.clientY - r.top - s / 2}px`;
n.addEventListener("animationend", () => n.remove()); el.appendChild(n);
if (REDUCED) return;
for (let i = 0; i < 7; i++) {
const p = document.createElement("span"), a = rnd(-3.1, 0), d = rnd(18, 42);
p.className = "sp"; p.style.cssText = `left:${e.clientX}px;top:${e.clientY}px;--dx:${Math.cos(a) * d}px;--dy:${Math.sin(a) * d + 14}px`;
p.addEventListener("animationend", () => p.remove()); document.body.appendChild(p);
}
}
+5
View File
@@ -0,0 +1,5 @@
export const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
export const LOW = (navigator.hardwareConcurrency || 8) <= 4 || (navigator.deviceMemory || 8) <= 4;
export const CAP = LOW ? 0.5 : 1; // particle cap by device speed
export const rnd = (a, b) => a + Math.random() * (b - a);
+48
View File
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { CAP, rnd } from './env.js';
export function useRain(canvasRef, on, levelRef) {
useEffect(() => {
const c = canvasRef.current, ctx = c.getContext("2d");
let w, H, raf = 0, run = false;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resize = () => { w = innerWidth; H = innerHeight; c.width = w * dpr; c.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); };
resize();
const mk = (near) => ({ x: rnd(0, w + 80), y: rnd(-H, H), v: near ? rnd(5, 8) : rnd(12, 20), l: near ? rnd(10, 18) : rnd(12, 30), a: near ? .5 : rnd(.12, .35) });
const far = Array.from({ length: 300 }, () => mk(false)), near = Array.from({ length: 36 }, () => mk(true));
const glass = Array.from({ length: 9 }, () => ({ x: rnd(0, w), y: rnd(0, H), r: rnd(2, 4.5), v: 0, wait: rnd(0, 200), t: [] }));
const draw = () => {
const L = levelRef.current, nf = Math.round((40 + 260 * L) * CAP), nn = Math.round((4 + 32 * L) * CAP), wind = .08 + .25 * L, sp = .8 + .6 * L;
ctx.clearRect(0, 0, w, H); ctx.lineCap = "round";
for (let i = 0; i < nf; i++) {
const s = far[i]; s.y += s.v * sp; s.x -= s.v * sp * wind;
if (s.y > H + 30 || s.x < -40) { s.y = rnd(-60, -5); s.x = rnd(0, w + 80); }
ctx.strokeStyle = `rgba(175,210,245,${s.a})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(s.x + s.l * wind, s.y - s.l); ctx.stroke();
}
for (let i = 0; i < nn; i++) {
const s = near[i]; s.y += s.v * sp; s.x -= s.v * sp * wind * .6;
if (s.y > H + 30 || s.x < -40) { s.y = rnd(-60, -5); s.x = rnd(0, w + 80); }
ctx.fillStyle = "rgba(190,225,255,.25)"; ctx.beginPath(); ctx.ellipse(s.x, s.y, 1.6, s.l / 2, .12, 0, 6.3); ctx.fill();
}
for (const g of glass) {
if (g.wait > 0) g.wait--; else {
g.v = Math.min(g.v + .02, 1.6) * (Math.random() < .03 ? .2 : 1); g.y += g.v; g.x += rnd(-.12, .12);
g.t.push([g.x, g.y]); if (g.t.length > 28) g.t.shift();
if (g.y > H + 10) { g.y = rnd(-40, H * .4); g.x = rnd(0, w); g.v = 0; g.t = []; g.wait = rnd(40, 260); }
}
if (g.t.length > 1) { ctx.strokeStyle = "rgba(170,210,255,.10)"; ctx.lineWidth = g.r * .6; ctx.beginPath(); g.t.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.stroke(); }
const gr = ctx.createRadialGradient(g.x - g.r * .3, g.y - g.r * .3, 0, g.x, g.y, g.r);
gr.addColorStop(0, "rgba(230,245,255,.55)"); gr.addColorStop(1, "rgba(120,170,220,.12)");
ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(g.x, g.y, g.r, 0, 6.3); ctx.fill();
}
raf = requestAnimationFrame(draw);
};
const start = () => { if (!run && on && !document.hidden) { run = true; raf = requestAnimationFrame(draw); } };
const stop = () => { run = false; cancelAnimationFrame(raf); };
const vis = () => (document.hidden ? stop() : start());
ctx.clearRect(0, 0, w, H); start();
document.addEventListener("visibilitychange", vis); addEventListener("resize", resize);
return () => { stop(); document.removeEventListener("visibilitychange", vis); removeEventListener("resize", resize); ctx.clearRect(0, 0, w, H); };
}, [on]);
}
+16
View File
@@ -0,0 +1,16 @@
export function makeSound() {
let ac, g, hp;
return {
start() {
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
ac = new AC();
const buf = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate), d = buf.getChannelData(0); let last = 0;
for (let i = 0; i < d.length; i++) { last = (last + .04 * (Math.random() * 2 - 1)) / 1.04; d[i] = last * 3.5; }
const src = ac.createBufferSource(); src.buffer = buf; src.loop = true;
hp = ac.createBiquadFilter(); hp.type = "highpass"; g = ac.createGain(); g.gain.value = 0;
src.connect(hp); hp.connect(g); g.connect(ac.destination); src.start();
},
set(l) { if (!g) return; g.gain.setTargetAtTime(.08 + .5 * l, ac.currentTime, .3); hp.frequency.value = 700 - 500 * l; },
stop() { try { ac && ac.close(); } catch (e) {} ac = g = hp = null; },
};
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(<App />);
+50
View File
@@ -0,0 +1,50 @@
:root{--bg:#0a111e;--bg2:#101b2d;--text:#e6f0fa;--dim:#a9bbd0;--teal:#5fd1c8;--amber:#ffb347;--edge:rgba(170,210,255,.2);
box-sizing:border-box;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
*,*::before,*::after{box-sizing:inherit}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);color-scheme:dark;font-family:'DM Sans',system-ui,-apple-system,'Segoe UI',sans-serif;-webkit-tap-highlight-color:transparent}
#root{min-height:100vh}
body{background:radial-gradient(120% 80% at 50% 0%,var(--bg2),var(--bg) 70%) fixed}
canvas.rain{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:0}
.flash{position:fixed;inset:0;background:#cfe3ff;opacity:0;pointer-events:none;z-index:1}
.flash.on{animation:fl .6s ease-out}
@keyframes fl{0%{opacity:0}8%{opacity:.3}20%{opacity:.04}32%{opacity:.2}100%{opacity:0}}
main{position:relative;z-index:2;max-width:440px;margin:0 auto;padding:26px 20px 28px;min-height:100vh;display:flex;flex-direction:column;align-items:center}
.mascot{filter:drop-shadow(0 0 16px rgba(95,209,200,.4))}
.umb{transform-origin:70px 54px;animation:bob 4s ease-in-out infinite}
.tail{transform-origin:92px 108px;animation:tail 2.6s ease-in-out infinite}
.e1{transform-origin:61px 68px;animation:twitch 6s infinite}
.eyes{transform-origin:70px 80px;animation:blink 5s infinite}
.rd{animation:rdrip 1.6s ease-in infinite}.r2{animation-delay:.5s}.r3{animation-delay:.9s}
@keyframes bob{50%{transform:translateY(-2px) rotate(1.5deg)}}
@keyframes tail{50%{transform:rotate(-14deg)}}
@keyframes twitch{0%,90%,100%{transform:rotate(0)}94%{transform:rotate(-14deg)}}
@keyframes blink{0%,94%,100%{transform:scaleY(1)}97%{transform:scaleY(.1)}}
@keyframes rdrip{0%{transform:translateY(0);opacity:0}15%{opacity:1}100%{transform:translateY(34px);opacity:0}}
h1{font:600 28px 'Fredoka',system-ui,sans-serif;margin:8px 0 4px;letter-spacing:.02em}
.tag{margin:0 0 22px;color:var(--dim);font-size:15px;text-align:center;min-height:22px;animation:fade .7s}
@keyframes fade{from{opacity:0}}
.links{width:100%;display:flex;flex-direction:column;gap:14px}
.wrap{position:relative}
.btn{position:relative;overflow:hidden;display:flex;align-items:center;gap:14px;width:100%;min-height:56px;padding:0 18px;border-radius:16px;color:var(--text);text-decoration:none;font-weight:500;font-size:16px;text-shadow:0 1px 6px rgba(0,0,0,.7);
background:rgba(14,26,46,.5);border:1px solid var(--edge);-webkit-backdrop-filter:blur(10px) saturate(1.2);backdrop-filter:blur(10px) saturate(1.2);transition:transform .15s}
.btn:active{transform:scale(.985)}
.btn:focus-visible,.tog:focus-visible{outline:2px solid var(--teal);outline-offset:3px}
.btn>*{position:relative;z-index:1}
.btn>canvas.fog{position:absolute;inset:0;width:100%;height:100%;z-index:0;opacity:.3;pointer-events:none}
.ico{flex:none;width:32px;height:32px;border-radius:10px;display:grid;place-items:center;font:600 15px 'Fredoka',system-ui,sans-serif;color:var(--teal);background:rgba(95,209,200,.14);text-shadow:none}
.ico-svg{width:20px;height:20px;filter:brightness(0) saturate(100%) invert(73%) sepia(30%) saturate(600%) hue-rotate(130deg) brightness(95%) contrast(90%)}
.sub{display:block;color:var(--dim);font-size:12.5px;font-weight:400;margin-top:1px}
.btn.feat{min-height:84px;border-color:rgba(255,179,71,.55);font-size:18px;background:linear-gradient(135deg,rgba(255,179,71,.22),rgba(14,26,46,.5));box-shadow:0 0 34px rgba(255,179,71,.18),inset 0 0 22px rgba(255,179,71,.08)}
.btn.feat .ico{width:44px;height:44px;border-radius:14px;font-size:20px;background:var(--amber)}
.bead{position:absolute!important;top:-6px;width:5px;height:8px;border-radius:50% 50% 50% 50%/60% 60% 40% 40%;background:rgba(200,230,255,.55);opacity:0;animation:slide 7s ease-in infinite;z-index:1;pointer-events:none}
@keyframes slide{0%{transform:translateY(0);opacity:0}8%{opacity:.8}60%{transform:translateY(46px)}100%{transform:translateY(88px);opacity:0}}
.drip{position:absolute;top:calc(100% - 3px);width:6px;height:8px;border-radius:50% 50% 50% 50%/60% 60% 40% 40%;background:rgba(200,230,255,.7);opacity:0;animation:drip 6s ease-in infinite;pointer-events:none}
@keyframes drip{0%{transform:scale(0);opacity:0}30%{transform:scale(1);opacity:.9}55%{transform:translateY(1px) scaleY(1.4);opacity:.9}100%{transform:translateY(26px) scaleY(1.6);opacity:0}}
.ripple{position:absolute;border-radius:50%;background:rgba(190,230,255,.35);transform:scale(0);animation:rp .6s ease-out forwards;pointer-events:none;z-index:1}
@keyframes rp{to{transform:scale(1);opacity:0}}
.sp{position:fixed;width:5px;height:5px;margin:-2px;border-radius:50%;background:rgba(200,230,255,.85);pointer-events:none;z-index:9;animation:sp .55s ease-out forwards}
@keyframes sp{to{transform:translate(var(--dx),var(--dy)) scale(.2);opacity:0}}
footer{margin-top:auto;padding-top:26px;display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.tog{min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--edge);background:rgba(14,26,46,.55);color:var(--dim);font:500 13px 'DM Sans',system-ui,sans-serif;cursor:pointer}
.tog[aria-pressed=true]{color:var(--teal);border-color:rgba(95,209,200,.55)}
@media (prefers-reduced-motion:reduce){.flash.on,.ripple,.tag{animation:none}.mascot *{animation:none!important}}
+22
View File
@@ -0,0 +1,22 @@
// Cookieless, first-party tracking. Requests go to /api/t/* on the same domain.
let visitSent = false;
const off = () => import.meta.env.DEV || navigator.doNotTrack === '1';
function send(url, body) {
const data = JSON.stringify(body);
try {
if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }))) return;
} catch (e) { /* fall through to fetch */ }
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: data, keepalive: true }).catch(() => {});
}
export function trackVisit() {
if (visitSent || off()) return;
visitSent = true;
send('/api/t/visit', { ref: document.referrer });
}
export function trackClick(link) {
if (off()) return;
send('/api/t/click', { link });
}