|
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="utf-8" /> |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 6 | + <title>Smithcv</title> |
| 7 | + <meta name="description" content="Coding projects" /> |
| 8 | + <style> |
| 9 | + :root{ |
| 10 | + --bg: #0b1020; --fg:#e6e8ef; --muted:#9aa3b2; --accent:#4fd1c5; --card:#12182b; |
| 11 | + --ring: 0 0 0 2px var(--accent); |
| 12 | + } |
| 13 | + :root.light{ --bg:#f8fafc; --fg:#0b1020; --muted:#475569; --accent:#2563eb; --card:#ffffff; } |
| 14 | + html,body{height:100%} |
| 15 | + body{ |
| 16 | + margin:0; font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; |
| 17 | + color:var(--fg); background:radial-gradient(1200px 800px at 70% -10%, rgba(79,209,197,.15), transparent 40%), var(--bg); |
| 18 | + overflow-x:hidden; |
| 19 | + } |
| 20 | + a{color:var(--accent); text-decoration:none} |
| 21 | + header{position:sticky; top:0; backdrop-filter:saturate(180%) blur(10px); background:color-mix(in oklab, var(--bg), transparent 60%); border-bottom:1px solid color-mix(in oklab, var(--fg), transparent 92%); z-index:20} |
| 22 | + .nav{max-width:1100px; margin:auto; display:flex; align-items:center; gap:16px; padding:12px 20px} |
| 23 | + .nav .brand{font-weight:700; letter-spacing:.2px} |
| 24 | + .nav .spacer{flex:1} |
| 25 | + .btn{display:inline-flex; align-items:center; gap:8px; padding:10px 14px; border-radius:14px; background:var(--card); border:1px solid color-mix(in oklab, var(--fg), transparent 88%); color:var(--fg); cursor:pointer} |
| 26 | + .btn:hover{box-shadow:var(--ring)} |
| 27 | + |
| 28 | + .hero{position:relative; isolation:isolate} |
| 29 | + canvas#bg{position:absolute; inset:0; width:100%; height:520px; display:block; z-index:-1} |
| 30 | + .hero-inner{max-width:1100px; margin:0 auto; padding:80px 20px 40px} |
| 31 | + .kicker{color:var(--muted); text-transform:uppercase; letter-spacing:.18em; font-size:.85rem} |
| 32 | + .title{font-weight:800; font-size:clamp(2rem, 6vw, 3.4rem); line-height:1.05; margin:.3rem 0 .6rem} |
| 33 | + .subtitle{color:var(--muted); max-width:60ch} |
| 34 | + .cta{margin-top:22px; display:flex; gap:12px; flex-wrap:wrap} |
| 35 | + |
| 36 | + .grid{max-width:1100px; margin:50px auto; padding:0 20px; display:grid; grid-template-columns:repeat(auto-fit, minmax(260px,1fr)); gap:18px} |
| 37 | + .card{background:var(--card); border:1px solid color-mix(in oklab, var(--fg), transparent 88%); border-radius:18px; padding:18px; transform:translateY(8px); opacity:0; transition:.6s ease; will-change:transform,opacity} |
| 38 | + .card.visible{transform:translateY(0); opacity:1} |
| 39 | + .card h3{margin:.2rem 0 .2rem} |
| 40 | + .muted{color:var(--muted)} |
| 41 | + |
| 42 | + .tilt{perspective:800px} |
| 43 | + .tilt > .card{transform:rotateX(0) rotateY(0); transition:transform .1s ease, box-shadow .2s ease} |
| 44 | + |
| 45 | + section{max-width:1100px; margin:70px auto; padding:0 20px} |
| 46 | + .searchbar{display:flex; gap:10px; align-items:center} |
| 47 | + input[type="search"]{flex:1; padding:12px 14px; border-radius:14px; border:1px solid color-mix(in oklab, var(--fg), transparent 85%); background:var(--card); color:var(--fg)} |
| 48 | + |
| 49 | + footer{max-width:1100px; margin:80px auto 40px; padding:0 20px; color:var(--muted)} |
| 50 | + .badges{display:flex; gap:10px; flex-wrap:wrap} |
| 51 | + .badge{border:1px solid color-mix(in oklab, var(--fg), transparent 85%); padding:6px 10px; border-radius:999px} |
| 52 | + |
| 53 | + @media (prefers-reduced-motion: reduce) { .card{transition:none} } |
| 54 | + </style> |
| 55 | +</head> |
| 56 | +<body> |
| 57 | + <header> |
| 58 | + <nav class="nav"> |
| 59 | + <div class="brand">⚓ Smithcv</div> |
| 60 | + <a href="#features">Features</a> |
| 61 | + <a href="#demo">API Demo</a> |
| 62 | + <a href="#faq">FAQ</a> |
| 63 | + <div class="spacer"></div> |
| 64 | + <button id="themeToggle" class="btn" aria-label="Toggle theme">🌗 Theme</button> |
| 65 | + <a class="btn" href="https://github.com/new" target="_blank" rel="noopener">↗ Deploy</a> |
| 66 | + </nav> |
| 67 | + </header> |
| 68 | + |
| 69 | + <section class="hero"> |
| 70 | + <canvas id="bg" aria-hidden="true"></canvas> |
| 71 | + <div class="hero-inner"> |
| 72 | + <div class="kicker">Static • Fast • Fancy</div> |
| 73 | + <h1 class="title">A cool JavaScript static website ✨</h1> |
| 74 | + <p class="subtitle">No backend needed. Canvas particles, scroll animations, tilt effects, theme toggle, client‑side API calls, and a searchable gallery — all GitHub Pages‑friendly.</p> |
| 75 | + <div class="cta"> |
| 76 | + <a class="btn" href="#features">See features</a> |
| 77 | + <button id="installPWA" class="btn" hidden>⬇️ Install</button> |
| 78 | + </div> |
| 79 | + </div> |
| 80 | + </section> |
| 81 | + |
| 82 | + <div id="features" class="grid tilt"> |
| 83 | + <article class="card"><h3>Canvas Stars</h3><p class="muted">Animated particle field using <code><canvas></code>.</p></article> |
| 84 | + <article class="card"><h3>Theme Toggle</h3><p class="muted">Dark/light with persistent preference.</p></article> |
| 85 | + <article class="card"><h3>Scroll Reveal</h3><p class="muted">IntersectionObserver for subtle entrances.</p></article> |
| 86 | + <article class="card"><h3>Card Tilt</h3><p class="muted">3D parallax on hover with pointer tracking.</p></article> |
| 87 | + <article class="card"><h3>API Demo</h3><p class="muted">Client-side fetch; no servers required.</p></article> |
| 88 | + <article class="card"><h3>PWA Ready</h3><p class="muted">Offline cache via service worker.</p></article> |
| 89 | + </div> |
| 90 | + |
| 91 | + <section id="demo"> |
| 92 | + <h2>API demo (client‑side)</h2> |
| 93 | + <p class="muted">Click the button to fetch a random programming joke (from <a href="https://v2.jokeapi.dev" target="_blank" rel="noopener">JokeAPI</a>).</p> |
| 94 | + <p><button id="jokeBtn" class="btn">🎲 Get a joke</button></p> |
| 95 | + <blockquote id="joke" class="muted">(Your joke will appear here)</blockquote> |
| 96 | + </section> |
| 97 | + |
| 98 | + <section id="gallery"> |
| 99 | + <h2>Searchable gallery</h2> |
| 100 | + <div class="searchbar"> |
| 101 | + <input id="search" type="search" placeholder="Filter cards by tag (e.g., ocean, robotics, ai)" /> |
| 102 | + <span class="muted" id="count"></span> |
| 103 | + </div> |
| 104 | + <div class="grid" id="cards"></div> |
| 105 | + </section> |
| 106 | + |
| 107 | + <footer> |
| 108 | + <div class="badges"> |
| 109 | + <span class="badge">100% static</span> |
| 110 | + <span class="badge">No build needed</span> |
| 111 | + <span class="badge">Works on GitHub Pages</span> |
| 112 | + <span class="badge">Progressive Web App</span> |
| 113 | + </div> |
| 114 | + <p class="muted">© <span id="year"></span> Smithcv — Built with vanilla JS. <a href="#">Back to top ↑</a></p> |
| 115 | + </footer> |
| 116 | + |
| 117 | + <script> |
| 118 | + // ——— Theme toggle ——— |
| 119 | + const root = document.documentElement; |
| 120 | + const themeToggle = document.getElementById('themeToggle'); |
| 121 | + const saved = localStorage.getItem('theme'); |
| 122 | + if(saved === 'light') root.classList.add('light'); |
| 123 | + themeToggle.addEventListener('click', () => { |
| 124 | + root.classList.toggle('light'); |
| 125 | + localStorage.setItem('theme', root.classList.contains('light') ? 'light' : 'dark'); |
| 126 | + }); |
| 127 | + |
| 128 | + // ——— Canvas particles (stars) ——— |
| 129 | + const c = document.getElementById('bg'); |
| 130 | + const ctx = c.getContext('2d'); |
| 131 | + let pixels = [], w, h, dpr = Math.min(2, window.devicePixelRatio || 1); |
| 132 | + function resize(){ w = c.width = Math.floor(innerWidth * dpr); h = c.height = Math.floor(520 * dpr); c.style.height = '520px'; } |
| 133 | + function spawn(){ |
| 134 | + pixels = Array.from({length: Math.floor((w*h)/(12000*dpr))}, () => ({ |
| 135 | + x: Math.random()*w, |
| 136 | + y: Math.random()*h, |
| 137 | + z: Math.random()*0.8+0.2, |
| 138 | + vx:(Math.random()-.5)*.2, vy:(Math.random()-.5)*.2 |
| 139 | + })); |
| 140 | + } |
| 141 | + function tick(){ |
| 142 | + ctx.clearRect(0,0,w,h); |
| 143 | + ctx.fillStyle = '#ffffff'; |
| 144 | + for(const p of pixels){ |
| 145 | + p.x += p.vx; p.y += p.vy; |
| 146 | + if(p.x<0||p.x>w) p.vx*=-1; if(p.y<0||p.y>h) p.vy*=-1; |
| 147 | + const r = p.z * dpr; |
| 148 | + ctx.globalAlpha = 0.35 + p.z*0.65; |
| 149 | + ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI*2); ctx.fill(); |
| 150 | + } |
| 151 | + requestAnimationFrame(tick); |
| 152 | + } |
| 153 | + addEventListener('resize', () => { resize(); spawn(); }); |
| 154 | + resize(); spawn(); tick(); |
| 155 | + |
| 156 | + // ——— Scroll reveal ——— |
| 157 | + const io = new IntersectionObserver(entries => { |
| 158 | + for(const e of entries){ if(e.isIntersecting){ e.target.classList.add('visible'); io.unobserve(e.target); } } |
| 159 | + }, {rootMargin: '0px 0px -10% 0px'}); |
| 160 | + document.querySelectorAll('.card').forEach(el => io.observe(el)); |
| 161 | + |
| 162 | + // ——— Tilt effect on feature cards ——— |
| 163 | + const tilt = document.querySelector('.tilt'); |
| 164 | + tilt.addEventListener('mousemove', e => { |
| 165 | + const rect = tilt.getBoundingClientRect(); |
| 166 | + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; |
| 167 | + tilt.querySelectorAll('.card').forEach(card => { |
| 168 | + const r = card.getBoundingClientRect(); |
| 169 | + const x = (cx - (r.left - rect.left) - r.width/2) / r.width; |
| 170 | + const y = (cy - (r.top - rect.top) - r.height/2) / r.height; |
| 171 | + card.style.transform = `rotateX(${-y*6}deg) rotateY(${x*8}deg)`; |
| 172 | + card.style.boxShadow = `0 10px 30px rgba(0,0,0,${0.2 + Math.abs(x*y)*0.2})`; |
| 173 | + }); |
| 174 | + }); |
| 175 | + tilt.addEventListener('mouseleave', () => { |
| 176 | + tilt.querySelectorAll('.card').forEach(card => { |
| 177 | + card.style.transform = 'rotateX(0) rotateY(0)'; card.style.boxShadow = ''; |
| 178 | + }); |
| 179 | + }); |
| 180 | + |
| 181 | + // ——— API demo ——— |
| 182 | + const jokeBtn = document.getElementById('jokeBtn'); |
| 183 | + const jokeEl = document.getElementById('joke'); |
| 184 | + jokeBtn.addEventListener('click', async () => { |
| 185 | + jokeEl.textContent = 'Loading…'; |
| 186 | + try{ |
| 187 | + const r = await fetch('https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,sexist,explicit'); |
| 188 | + const j = await r.json(); |
| 189 | + jokeEl.textContent = j.type === 'single' ? j.joke : `${j.setup} — ${j.delivery}`; |
| 190 | + }catch(err){ jokeEl.textContent = 'Could not load a joke. (Check network/CORS)'; } |
| 191 | + }); |
| 192 | + |
| 193 | + // ——— Searchable gallery ——— |
| 194 | + const data = [ |
| 195 | + { title:'NEPTUNE AUV', tags:['ocean','robotics','auv'] }, |
| 196 | + { title:'Bathymetry Map', tags:['mapping','gis','ocean'] }, |
| 197 | + { title:'Telemetry Dashboard', tags:['ui','charts','ai'] }, |
| 198 | + { title:'Hull CFD', tags:['simulation','fluid','robotics'] }, |
| 199 | + { title:'Docking Station', tags:['hardware','auv'] }, |
| 200 | + { title:'Mission Planner', tags:['ui','planning','robotics'] }, |
| 201 | + ]; |
| 202 | + const cardsEl = document.getElementById('cards'); |
| 203 | + const countEl = document.getElementById('count'); |
| 204 | + const search = document.getElementById('search'); |
| 205 | + function render(filter=''){ |
| 206 | + const q = filter.trim().toLowerCase(); |
| 207 | + const items = data.filter(x => !q || x.tags.some(t => t.includes(q)) || x.title.toLowerCase().includes(q)); |
| 208 | + cardsEl.innerHTML = items.map(x => `<article class="card visible"><h3>${x.title}</h3><p class="muted">Tags: ${x.tags.join(', ')}</p></article>`).join(''); |
| 209 | + countEl.textContent = `${items.length}/${data.length}`; |
| 210 | + } |
| 211 | + render(); |
| 212 | + search.addEventListener('input', e => render(e.target.value)); |
| 213 | + |
| 214 | + // ——— PWA bits ——— |
| 215 | + const year = document.getElementById('year'); year.textContent = new Date().getFullYear(); |
| 216 | + let deferredPrompt; |
| 217 | + window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; document.getElementById('installPWA').hidden = false; }); |
| 218 | + document.getElementById('installPWA').addEventListener('click', async () => { deferredPrompt?.prompt(); deferredPrompt = null; }); |
| 219 | + |
| 220 | + if('serviceWorker' in navigator){ navigator.serviceWorker.register('./sw.js').catch(()=>{}); } |
| 221 | + </script> |
| 222 | + |
| 223 | + <!-- Minimal service worker for offline caching (create sw.js in the same folder) --> |
| 224 | + <script type="module"> |
| 225 | + // If sw.js doesn't exist yet, dynamically create it on first run (Pages will serve it as a static file once committed) |
| 226 | + (async () => { |
| 227 | + try{ |
| 228 | + const res = await fetch('./sw.js', {cache:'no-store'}); |
| 229 | + if(!res.ok){ |
| 230 | + const sw = `const CACHE='oa-cache-v1'; |
| 231 | +self.addEventListener('install', e=>{ e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['./','./index.html'])))}); |
| 232 | +self.addEventListener('fetch', e=>{ e.respondWith(caches.match(e.request).then(r=> r || fetch(e.request))) });`; |
| 233 | + const blob = new Blob([sw], {type:'text/javascript'}); |
| 234 | + const url = URL.createObjectURL(blob); |
| 235 | + // No filesystem write available here; this is just for local preview if running from a file server. |
| 236 | + } |
| 237 | + }catch{} |
| 238 | + })(); |
| 239 | + </script> |
| 240 | +</body> |
| 241 | +</html> |
0 commit comments