diff --git a/README.md b/README.md index 8401866..eba15cc 100644 --- a/README.md +++ b/README.md @@ -338,20 +338,32 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @Mudita-Singh + + + Naina123kashyap
+ @Naina123kashyap +
+ Naveen-Boddepalli
@Naveen-Boddepalli
+ + + + + Nencypatel21
+ @Nencypatel21 +
+ NidhiS-7
@NidhiS-7
- - PIYUSH-NEXTGEN
@@ -376,6 +388,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @PragatiNigam29
+ + Quantum3600
@@ -388,8 +402,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @Ranjanmaiti6
- - Sanjhivvarshan-b-s
@@ -414,6 +426,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @Shital861
+ + Shivi-Srivastava-4444
@@ -426,8 +440,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @Siddh2024
- - Sparshjoshi-iit
@@ -452,6 +464,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @Tech4Aditya
+ + TheBinaryAVA
@@ -464,8 +478,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @Tiago-Vier-Preto
- - Vanshikakhasat
@@ -490,6 +502,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @abhi-nav-25
+ + advikdivekar
@@ -502,8 +516,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @anujsharma8d
- - artic2702
@@ -528,6 +540,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @avzuha
+ + ayushyadav0707
@@ -540,8 +554,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @bhavyapandiya29
- - codewithakshyaaa
@@ -566,6 +578,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @gatiksolanki13-netizen
+ + iamprasoon2006-cell
@@ -578,8 +592,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @itsdakshjain
- - jeetrouth
@@ -604,6 +616,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @madhavcodes25
+ + mahi-8758
@@ -616,8 +630,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @mrinmoyChakraborty-mrinox
- - n1o5
@@ -642,6 +654,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @nishtha-agarwal-211
+ + parasmani-dev
@@ -654,8 +668,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @parulpaliwal01
- - pragya-manna
@@ -680,6 +692,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @r-sushanth08
+ + rashiaggarwal06
@@ -692,8 +706,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @rishit537
- - sakshicoded1111
@@ -718,6 +730,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @shimonenator
+ + shreyasarote7717-cyber
@@ -730,8 +744,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @shreyasgawande19
- - siri-004
@@ -756,6 +768,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @steam-bell-92
+ + sujitsingh8
@@ -768,8 +782,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @tanishkakora
- - twinkle0tech
@@ -794,6 +806,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @vedikabajaj05
+ + vivekCS007
@@ -806,6 +820,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. @yuvraj-k-singh
+ + + + diff --git a/WEB_APP_GUIDE.md b/WEB_APP_GUIDE.md new file mode 100644 index 0000000..3e09762 --- /dev/null +++ b/WEB_APP_GUIDE.md @@ -0,0 +1,3381 @@ +# ๐Ÿš€ Web App Architecture Guide + +
+ +![Web App Guide Header](https://readme-typing-svg.herokuapp.com?font=Fira+Code&size=32&duration=3000&pause=1000&color=22C55E¢er=true&vCenter=true&width=900&lines=๐Ÿš€+Web+App+Architecture+Guide;โšก+Interactive+Frontend+Documentation;๐Ÿ+Vanilla+JavaScript+%26+Pyodide+%26+Web+Workers) + +
+ +> **Python Mini Projects** โ€” Interactive Frontend Documentation +> A modern, accessible web experience powered by vanilla JavaScript, Pyodide, and Web Workers. + +``` +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + ๐ŸŽจ Welcome to the Guide + Architecture | Components | Design | Accessibility +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +``` + + +## ๐Ÿ“‘ Quick Navigation + +| Section | Purpose | Time | +|---------|---------|------| +| ๐ŸŽฏ [Architecture](#-system-architecture) | Core system design | 5 min | +| ๐ŸŽจ [Components](#-component-library) | UI component reference | 8 min | +| ๐ŸŽญ [Design System](#-design-system) | Colors, typography, animations | 6 min | +| โ™ฟ [Accessibility](#-accessibility-standards) | WCAG 2.1 AA compliance | 7 min | +| ๐Ÿ“ฑ [Responsive Design](#-responsive-design) | Mobile-first approach | 5 min | +| ๐Ÿ‘จโ€๐Ÿ’ป [Contributing](#-contributor-guide) | Setup & workflow | 10 min | +| โœจ [Adding Features](#-adding-new-projects) | Step-by-step guides | 10 min | + +--- + +## ๐ŸŽฏ System Architecture + +### High-Level Overview + +```mermaid +graph TB + subgraph Browser["๐ŸŒ Browser Execution"] + DOM["DOM\n(HTML)"] + CSS["Styles\n(CSS Variables)"] + MainThread["Main Thread\n(UI & Events)"] + Worker["Web Worker\n(Python Execution)"] + end + + subgraph Modules["๐Ÿ“ฆ JavaScript Modules"] + main["main.js\n(orchestration)"] + playground["playground.js\n(Pyodide)"] + projects["projects.js\n(registry)"] + storage["storage.js\n(persistence)"] + audio["audio.js\n(sounds)"] + end + + External["๐Ÿ”Œ Pyodide\n(Python Runtime)"] + + MainThread -->|control| DOM + MainThread -->|wire events| main + main -->|message passing| playground + playground -->|Web Worker| Worker + Worker -->|execute async| External + main -->|read/write| storage + main -->|play| audio +``` + +### Project Philosophy + +**Core Principles:** + +1. **Zero Installation** โ€” Run in browsers without Python install +2. **Progressive Enhancement** โ€” Works with minimal JS, scales up +3. **Accessibility First** โ€” WCAG 2.1 Level AA built-in +4. **Mobile-First** โ€” Designed for mobile, scales to desktop +5. **Modular Architecture** โ€” Independent, testable modules +6. **Performance** โ€” Non-blocking execution, optimized rendering + +### Thread Model: Why Web Workers? ๐Ÿงต + +Python code execution happens on a **separate thread** to prevent UI freezing: + +```mermaid +sequenceDiagram + participant User + participant MainThread as Main Thread + participant Worker as Web Worker + participant Pyodide as Python Runtime + + User->>MainThread: Click "Run Code" + Note over MainThread: โœ… Still responsive! + MainThread->>MainThread: UI remains interactive + MainThread->>Worker: postMessage(python_code) + Note over Worker: Long computation + Worker->>Pyodide: Execute python asynchronously + Pyodide->>Pyodide: Heavy processing... + Pyodide->>Worker: Returns stdout/stderr + Worker->>MainThread: postMessage(result) + MainThread->>User: Display result instantly +``` + +**Benefits:** +- โœ… UI never freezes during computation +- โœ… Users can interact while code runs +- โœ… Click events process instantly +- โœ… Multiple computations can queue + +### Infinite Loop Protection โšก + +The critical challenge: **How to stop an infinite loop?** + +**Solution: Worker Termination** + +```javascript +// When user clicks "Stop" +function stopExecution() { + if (worker) { + worker.terminate(); // โ† Instantly kills the thread + worker = null; + spawnWorker(); // โ† Create fresh worker + } +} +``` + +**Why this works:** +- `terminate()` immediately halts the worker thread +- Even mid-infinite-loop, execution stops instantly +- No timeout hacks needed +- Fresh worker loads in ~100ms from cache + +--- + +## ๐ŸŽจ Component Library + +### ๐Ÿ”˜ Button Component + +**Purpose:** Trigger actions โ€” submit forms, launch projects, toggle features + +```html + + + + + + + + + + + +``` + +**CSS Structure:** + +```css +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary { + background-color: var(--primary-color); + color: var(--on-accent); +} + +.btn-primary:hover { + filter: brightness(1.1); + box-shadow: var(--shadow); +} + +.btn:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +``` + +**Variants:** +| Variant | Usage | +|---------|-------| +| `btn-primary` | Main CTA, important actions | +| `btn-secondary` | Alternative actions | +| `btn-danger` | Destructive actions | +| `btn-icon` | Icon-only (must have aria-label) | + +**Accessibility:** +- โœ… Keyboard focusable (Tab key) +- โœ… Enter/Space activates +- โœ… Focus indicator visible +- โœ… Minimum 44ร—44px hit target +- โœ… Screen reader friendly + +--- + +### ๐Ÿƒ Card Component + +**Purpose:** Display project information in scannable units + +```html +
+
+

๐ŸŽฎ Rock Paper Scissors

+ Game +
+ +

+ Classic strategy game with AI opponent. Master the mind games! +

+ +
+ Beginner + โฑ๏ธ 5 min +
+ + +
+``` + +**CSS:** + +```css +.project-card { + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + padding: 1.5rem; + transition: var(--transition); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.project-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow); + border-color: var(--primary-color); +} + +.project-card:focus-within { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.card-title { + font-size: 1.25rem; + margin: 0; + color: var(--text-color); +} + +.card-description { + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +.badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.85rem; + font-weight: 600; + background: var(--accent-soft); + color: var(--primary-color); + border: 1px solid var(--accent-border); +} +``` + +**Grid Responsive:** + +```css +.projects-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +@media (min-width: 640px) { + .projects-grid { + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + } +} + +@media (min-width: 1024px) { + .projects-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; + } +} +``` + +--- + +### ๐ŸชŸ Modal Component + +**Purpose:** Display project UIs in focused overlay without navigation + +```html + + +
+``` + +**CSS:** + +```css +.modal { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + animation: fadeIn 0.3s ease; +} + +.modal[hidden] { display: none; } + +.modal-overlay { + position: absolute; + inset: 0; + background: var(--overlay-color); + cursor: pointer; +} + +.modal-dialog { + position: relative; + z-index: 1; + background: var(--surface-color); + border-radius: 0.75rem; + max-width: 90vw; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-modal); + animation: slideUp 0.3s ease; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-close:focus { + outline: 2px solid var(--primary-color); + border-radius: 0.25rem; +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} +``` + +**Focus Management (JavaScript):** + +```javascript +function openProjectModal(projectName) { + const html = getProjectHTML(projectName); + modalBody.innerHTML = html; + modal.removeAttribute('hidden'); + mainContent.setAttribute('inert', ''); + modalClose.focus(); +} + +function closeProjectModal() { + modal.setAttribute('hidden', ''); + mainContent.removeAttribute('inert'); + triggerButton.focus(); +} + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !modal.hasAttribute('hidden')) { + closeProjectModal(); + } +}); + +document.getElementById('modalOverlay').addEventListener('click', closeProjectModal); +modalClose.addEventListener('click', closeProjectModal); +``` + +--- + +### ๐Ÿ” Search Component + +**Purpose:** Enable users to quickly find projects by name or keyword + +```html +
+ + + + +
+``` + +**Features:** +- Keyboard shortcut `/` to focus +- Debounced 300ms for performance +- Recent searches saved to localStorage +- Dropdown with suggestions +- Escape key closes dropdown + +--- + +### ๐Ÿ Python Playground + +**Purpose:** Interactive code editor and executor powered by Pyodide + +```html + +``` + +**Keyboard Shortcuts:** +| Shortcut | Action | +|----------|--------| +| `Ctrl+Enter` | Run code | +| `Tab` | Insert tab | + +--- + +### ๐ŸŽจ Theme Toggle + +**Purpose:** Allow users to switch between dark and light themes + +```javascript +function toggleTheme() { + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme') || 'dark'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + html.setAttribute('data-theme', newTheme); + const icon = document.getElementById('themeIcon'); + icon.className = newTheme === 'dark' ? 'fas fa-moon' : 'fas fa-sun'; + localStorage.setItem('theme', newTheme); +} + +function initTheme() { + const saved = localStorage.getItem('theme'); + if (saved) { + applyTheme(saved); + } else if (window.matchMedia('(prefers-color-scheme: light)').matches) { + applyTheme('light'); + } else { + applyTheme('dark'); + } +} + +initTheme(); +document.getElementById('themeToggle').addEventListener('click', toggleTheme); +``` + +--- + +## ๐ŸŽญ Design System + +### ๐ŸŒˆ Color Palette + +#### Dark Theme (Default) +```css +--primary-color: #22c55e /* Brand green */ +--secondary-color: #06b6d4 /* Cyan accent */ +--success-color: #10b981 /* Green success */ +--danger-color: #ef4444 /* Red errors */ +--warning-color: #f59e0b /* Orange warnings */ + +--bg-color: #081120 /* Deep blue background */ +--surface-color: #111827 /* Dark surface */ +--panel-color: #0f172a /* Input/nested bg */ +--text-color: #e5e7eb /* Light text */ +--text-secondary: #94a3b8 /* Muted text */ +--border-color: #1f2937 /* Subtle borders */ +``` + +#### Light Theme +```css +--primary-color: #16a34a /* Darker green */ +--bg-color: #f8fafc /* Light background */ +--surface-color: #ffffff /* White surfaces */ +--text-color: #1f2937 /* Dark text */ +--text-secondary: #6b7280 /* Muted text */ +--border-color: #d8dee8 /* Light borders */ +``` + +### ๐Ÿ“ Typography + +**Font Stack:** +```css +/* Headings */ +font-family: 'Fredoka', 'Segoe UI', sans-serif; + +/* Body */ +font-family: 'DM Sans', 'Segoe UI', sans-serif; + +/* Code */ +font-family: 'IBM Plex Mono', monospace; +``` + +**Responsive Font Sizes:** +```css +h1 { font-size: clamp(1.75rem, 5vw, 3rem); } +h2 { font-size: clamp(1.5rem, 4vw, 2.25rem); } +h3 { font-size: clamp(1.25rem, 3vw, 1.875rem); } + +body { + font-size: clamp(0.95rem, 2vw, 1.1rem); + line-height: 1.6; +} +``` + +--- + +### โœจ Animation & Motion + +**Standard Animations:** + +```css +/* Fade in entrance */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Slide up entrance */ +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Pulse (loading indicator) */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* Spin (loading spinner) */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Bounce (attention) */ +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +/* Glow effect */ +@keyframes glow { + 0%, 100% { box-shadow: 0 0 5px var(--primary-color); } + 50% { box-shadow: 0 0 20px var(--primary-color); } +} + +/* Shimmer loading effect */ +@keyframes shimmer { + 0% { background-position: -1000px 0; } + 100% { background-position: 1000px 0; } +} +``` + +**Transition Timing:** +```css +--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); +--motion-duration: 0.3s; +``` + +**Reduced Motion Support:** +```css +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +``` + +--- + +## โ™ฟ Accessibility Standards + +### WCAG 2.1 Level AA Compliance + +| Criterion | Implementation | +|-----------|-----------------| +| **Semantic HTML** | ` - -

+
+ +
- - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
+ +
+ + + + + + + +
+ +
+ + + + +
+
TRACK-ROUTE
+
+
+
+ +
+ 01 +

Welcome

+

Jump into a tiny Python arcade built for curious beginners

+
+
+
+
+
+
+
+
+
+ +
+ 02 +

Explore

+

Browse games, math puzzles & utilities โ€” each one ready in your browser

+
+
+
+
+
+ +
+ 03 +

Play

+

Launch any project instantly, experiment with code, and share your wins

+
+
+
+
+
+
+
+
+
+ +
+ 04 +

Learn

+

Pick up Python concepts as you play โ€” loops, logic, data & more

+
+
+
+
+
+ +
+ 05 +

Build

+

Create your own mini projects and share them with the community

+
+
+
+
+
+
+
+
+
+ +
+ 06 +

Share

+

Show off your work, get feedback, and keep growing as a dev

+
+
+
+
+
+
+ +
+
+
+

Projects

+ +
+
+
+ + - - +
- - - - + - + + - + + - + @@ -1621,67 +673,12 @@

Stay Updated

+ - - - - - + diff --git a/web-app/js/hero-canvas.js b/web-app/js/hero-canvas.js index ff44838..edc05de 100644 --- a/web-app/js/hero-canvas.js +++ b/web-app/js/hero-canvas.js @@ -1,297 +1,181 @@ -/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - ANIMATED SNAKES & LADDERS BOARD ON CANVAS - Fixed: canvas now fills 100% of hero section -โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ -const canvas = document.getElementById('boardCanvas'); - -if (canvas) { - const ctx = canvas.getContext('2d'); - - /* Force canvas to fill the hero section absolutely */ -(function positionCanvas() { - const hero = canvas.closest('.hero-section') || canvas.parentElement; - if (hero && getComputedStyle(hero).position === 'static') { - hero.style.position = 'relative'; - } - canvas.style.cssText = [ - 'position:absolute', - 'top:0', 'left:0', 'right:0', 'bottom:0', - 'width:100%', 'height:100%', - 'z-index:0', - 'pointer-events:none', - 'display:block', - ].join(';'); -})(); - -function resize() { - canvas.width = canvas.offsetWidth * devicePixelRatio; - canvas.height = canvas.offsetHeight * devicePixelRatio; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(devicePixelRatio, devicePixelRatio); -} -resize(); -window.addEventListener('resize', resize); - -const W = () => canvas.offsetWidth; -const H = () => canvas.offsetHeight; - -/* โ”€โ”€ Colour palette (pastel greens) โ”€โ”€ */ -const C = { - boardLight: '#fff5e6', - boardDark: '#f6ead7', - gridLine: 'rgba(132,97,67,0.12)', - snakeBody: '#f08a6a', - snakeHead: '#d46c4f', - ladderRail: '#d19a55', - ladderRung: '#f0c47c', - dice: 'rgba(255,255,255,0.88)', - diceDot: '#6abf8d', - token1: '#6abf8d', - token2: '#c7a36b', - num: 'rgba(132,97,67,0.45)', -}; - -/* โ”€โ”€ Board geometry โ”€โ”€ */ -const ROWS = 10, COLS = 10; - -function cellRect(col, row) { - const w = W(), h = H(); - const cw = w / COLS, ch = h / ROWS; - return { x: col * cw, y: (ROWS - 1 - row) * ch, w: cw, h: ch }; -} - -function cellCenter(n) { - const idx = n - 1; - const row = Math.floor(idx / COLS); - let col = idx % COLS; - if (row % 2 === 1) col = COLS - 1 - col; - const r = cellRect(col, row); - return { x: r.x + r.w / 2, y: r.y + r.h / 2 }; -} - -/* โ”€โ”€ Snakes & Ladders definitions โ”€โ”€ */ -const snakes = [[97,78],[95,56],[88,24],[76,37],[74,53],[62,19],[54,34],[19,7]]; -const ladders = [[4,25],[9,31],[20,41],[28,84],[40,59],[51,67],[63,81],[71,91]]; - -/* โ”€โ”€ Animated tokens โ”€โ”€ */ -class Token { - constructor(color, speed) { - this.cell = 1 + Math.floor(Math.random() * 100); - this.color = color; - this.speed = speed; - this.wait = Math.random() * 200; - this.r = 0; - const c = cellCenter(this.cell); - this.x = c.x; this.y = c.y; - this.tx = c.x; this.ty = c.y; - } - update() { - if (this.wait > 0) { this.wait--; return; } - const dx = this.tx - this.x, dy = this.ty - this.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < 1.5) { - this.x = this.tx; this.y = this.ty; - if (Math.random() < 0.015) { - this.cell = 1 + Math.floor(Math.random() * 100); - const c = cellCenter(this.cell); - this.tx = c.x; this.ty = c.y; - this.wait = 30 + Math.random() * 80; - } - } else { - this.x += dx * this.speed; - this.y += dy * this.speed; - } - this.r += 0.04; - } - draw() { - const s = Math.min(W(), H()) * 0.025; - ctx.save(); - ctx.translate(this.x, this.y); - ctx.rotate(Math.sin(this.r) * 0.3); - ctx.beginPath(); - ctx.arc(0, 0, s, 0, Math.PI * 2); - ctx.fillStyle = this.color; - ctx.shadowColor = this.color; - ctx.shadowBlur = 10; - ctx.fill(); - ctx.shadowBlur = 0; - ctx.restore(); - } -} - -const tokens = [ - new Token(C.token1, 0.06), - new Token(C.token2, 0.05), -]; - -/* โ”€โ”€ Dice animation โ”€โ”€ */ -const diceAnim = { val: 1, t: 0 }; - -/* โ”€โ”€ Draw board โ”€โ”€ */ -function drawBoard() { - const w = W(), h = H(); - ctx.fillStyle = '#fff9f0'; - ctx.fillRect(0, 0, w, h); - - for (let row = 0; row < ROWS; row++) { - for (let col = 0; col < COLS; col++) { - const r = cellRect(col, row); - ctx.fillStyle = (row + col) % 2 === 0 ? C.boardLight : C.boardDark; - ctx.fillRect(r.x, r.y, r.w, r.h); - ctx.strokeStyle = C.gridLine; - ctx.lineWidth = 0.5; - ctx.strokeRect(r.x, r.y, r.w, r.h); - - /* cell numbers */ - const cellNum = (row % 2 === 0) - ? (row * COLS + col + 1) - : (row * COLS + (COLS - col)); - ctx.fillStyle = C.num; - ctx.font = `bold ${Math.max(9, r.w * 0.22)}px Nunito, sans-serif`; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - ctx.fillText(cellNum, r.x + r.w * 0.08, r.y + r.h * 0.06); - } +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + hero-canvas.js โ€” Interactive Particle Mesh Background + Smooth, performance-first particle grid that responds to + mouse movement with subtle connections and gentle drift. + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +(function () { + 'use strict'; + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + function ParticleMesh(canvasId) { + var self = this; + this.canvas = document.getElementById(canvasId); + if (!this.canvas) return; + + this.ctx = this.canvas.getContext('2d'); + this.particles = []; + this.mouse = { x: null, y: null, radius: 120 }; + this.W = 0; + this.H = 0; + this.animFrame = null; + this.isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + + this.CONFIG = { + count: 60, + maxDist: 140, + speed: 0.15, + connectionOpacity: this.isDark ? 0.12 : 0.08, + particleRadius: this.isDark ? 3.0 : 2.5, + color: this.isDark ? '255,255,255' : '0,0,0', + mousePull: 0.03, + }; + + // Particle Object constructor inside instance scope + function Particle() { + this.x = Math.random() * self.W; + this.y = Math.random() * self.H; + this.vx = (Math.random() - 0.5) * self.CONFIG.speed; + this.vy = (Math.random() - 0.5) * self.CONFIG.speed; + this.phase = Math.random() * Math.PI * 2; } -} - -/* โ”€โ”€ Draw snakes โ”€โ”€ */ -function drawSnakes() { - snakes.forEach(([from, to]) => { - const a = cellCenter(from), b = cellCenter(to); - const t = performance.now() / 1200; - const segments = 20; - const amp = Math.min(W(), H()) * 0.025; - ctx.beginPath(); - for (let i = 0; i <= segments; i++) { - const p = i / segments; - const x = a.x + (b.x - a.x) * p + Math.sin(p * Math.PI * 3 + t) * amp; - const y = a.y + (b.y - a.y) * p; - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + Particle.prototype.update = function () { + this.phase += 0.005; + this.x += this.vx + Math.sin(this.phase) * 0.1; + this.y += this.vy + Math.cos(this.phase * 0.7) * 0.1; + + /* Mouse interaction */ + if (self.mouse.x !== null) { + var dx = self.mouse.x - this.x; + var dy = self.mouse.y - this.y; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < self.mouse.radius) { + var force = (self.mouse.radius - dist) / self.mouse.radius; + this.x -= dx * force * self.CONFIG.mousePull; + this.y -= dy * force * self.CONFIG.mousePull; } - ctx.strokeStyle = C.snakeBody; - ctx.lineWidth = Math.min(W(), H()) * 0.018; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.globalAlpha = 0.7; - ctx.stroke(); - ctx.globalAlpha = 1; - - /* head */ - ctx.beginPath(); - ctx.arc(a.x, a.y, Math.min(W(), H()) * 0.016, 0, Math.PI * 2); - ctx.fillStyle = C.snakeHead; - ctx.fill(); - - /* eyes */ - const eyeR = Math.min(W(), H()) * 0.004; - ctx.beginPath(); - ctx.arc(a.x - eyeR * 1.8, a.y - eyeR * 1.5, eyeR, 0, Math.PI * 2); - ctx.arc(a.x + eyeR * 1.8, a.y - eyeR * 1.5, eyeR, 0, Math.PI * 2); - ctx.fillStyle = '#fff'; - ctx.fill(); - }); -} - -/* โ”€โ”€ Draw ladders โ”€โ”€ */ -function drawLadders() { - ladders.forEach(([from, to]) => { - const a = cellCenter(from), b = cellCenter(to); - const dx = b.x - a.x, dy = b.y - a.y; - const len = Math.sqrt(dx * dx + dy * dy); - const ux = -dy / len, uy = dx / len; - const rail = Math.min(W(), H()) * 0.012; - - [rail, -rail].forEach(off => { - ctx.beginPath(); - ctx.moveTo(a.x + ux * off, a.y + uy * off); - ctx.lineTo(b.x + ux * off, b.y + uy * off); - ctx.strokeStyle = C.ladderRail; - ctx.lineWidth = Math.min(W(), H()) * 0.006; - ctx.globalAlpha = 0.75; - ctx.stroke(); - ctx.globalAlpha = 1; - }); - - const rungs = Math.round(len / (Math.min(W(), H()) * 0.065)); - for (let i = 1; i < rungs; i++) { - const p = i / rungs; - const rx = a.x + dx * p, ry = a.y + dy * p; - ctx.beginPath(); - ctx.moveTo(rx + ux * rail, ry + uy * rail); - ctx.lineTo(rx - ux * rail, ry - uy * rail); - ctx.strokeStyle = C.ladderRung; - ctx.lineWidth = Math.min(W(), H()) * 0.005; - ctx.globalAlpha = 0.7; - ctx.stroke(); - ctx.globalAlpha = 1; + } + + /* Wrap around edges */ + if (this.x < 0) this.x = self.W; + if (this.x > self.W) this.x = 0; + if (this.y < 0) this.y = self.H; + if (this.y > self.H) this.y = 0; + }; + + Particle.prototype.draw = function () { + self.ctx.beginPath(); + self.ctx.arc(this.x, this.y, self.CONFIG.particleRadius, 0, Math.PI * 2); + self.ctx.fillStyle = 'rgba(' + self.CONFIG.color + ', 0.6)'; + self.ctx.fill(); + }; + + this.resize = function () { + var rect = self.canvas.parentElement.getBoundingClientRect(); + self.W = self.canvas.width = rect.width * window.devicePixelRatio; + self.H = self.canvas.height = rect.height * window.devicePixelRatio; + self.canvas.style.width = rect.width + 'px'; + self.canvas.style.height = rect.height + 'px'; + self.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + self.W = rect.width; + self.H = rect.height; + }; + + this.drawConnections = function () { + var len = self.particles.length; + for (var i = 0; i < len; i++) { + for (var j = i + 1; j < len; j++) { + var dx = self.particles[i].x - self.particles[j].x; + var dy = self.particles[i].y - self.particles[j].y; + var dist = dx * dx + dy * dy; + + if (dist < self.CONFIG.maxDist * self.CONFIG.maxDist) { + var opacity = (1 - dist / (self.CONFIG.maxDist * self.CONFIG.maxDist)) * self.CONFIG.connectionOpacity; + self.ctx.beginPath(); + self.ctx.moveTo(self.particles[i].x, self.particles[i].y); + self.ctx.lineTo(self.particles[j].x, self.particles[j].y); + self.ctx.strokeStyle = 'rgba(' + self.CONFIG.color + ', ' + opacity + ')'; + self.ctx.lineWidth = 0.5; + self.ctx.stroke(); + } } + } + }; + + this.animate = function () { + self.ctx.clearRect(0, 0, self.W, self.H); + + for (var i = 0; i < self.particles.length; i++) { + self.particles[i].update(); + self.particles[i].draw(); + } + + self.drawConnections(); + + self.animFrame = requestAnimationFrame(self.animate.bind(self)); + }; + + this.onMouseMove = function (e) { + var rect = self.canvas.getBoundingClientRect(); + self.mouse.x = e.clientX - rect.left; + self.mouse.y = e.clientY - rect.top; + }; + + this.onMouseLeave = function () { + self.mouse.x = null; + self.mouse.y = null; + }; + + this.onThemeChange = function () { + self.isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + self.CONFIG.color = self.isDark ? '255,255,255' : '0,0,0'; + self.CONFIG.connectionOpacity = self.isDark ? 0.12 : 0.08; + self.CONFIG.particleRadius = self.isDark ? 3.0 : 2.5; + }; + + this.init = function () { + self.resize(); + self.particles = []; + for (var i = 0; i < self.CONFIG.count; i++) { + self.particles.push(new Particle()); + } + self.animate(); + }; + + this.destroy = function () { + if (self.animFrame) cancelAnimationFrame(self.animFrame); + }; + + // Listen to resize and hover events + window.addEventListener('resize', this.resize.bind(this)); + this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); + this.canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this)); + + this.init(); + } + + // Initialize animations on both canvases + var mesh1 = new ParticleMesh('boardCanvas'); + var mesh2 = new ParticleMesh('timelineCanvas'); + + /* โ”€โ”€ Observe theme changes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var themeObserver = new MutationObserver(function (mutations) { + mutations.forEach(function (m) { + if (m.attributeName === 'data-theme') { + if (mesh1 && typeof mesh1.onThemeChange === 'function') mesh1.onThemeChange(); + if (mesh2 && typeof mesh2.onThemeChange === 'function') mesh2.onThemeChange(); + } }); -} - -/* โ”€โ”€ Draw animated dice โ”€โ”€ */ -function roundRect(cx, x, y, w, h, r) { - cx.beginPath(); - cx.moveTo(x + r, y); - cx.lineTo(x + w - r, y); cx.arcTo(x + w, y, x + w, y + r, r); - cx.lineTo(x + w, y + h - r); cx.arcTo(x + w, y + h, x + w - r, y + h, r); - cx.lineTo(x + r, y + h); cx.arcTo(x, y + h, x, y + h - r, r); - cx.lineTo(x, y + r); cx.arcTo(x, y, x + r, y, r); - cx.closePath(); -} - -function diceDots(v) { - return { - 1: [[0, 0]], - 2: [[-1,-1],[1,1]], - 3: [[-1,-1],[0,0],[1,1]], - 4: [[-1,-1],[1,-1],[-1,1],[1,1]], - 5: [[-1,-1],[1,-1],[0,0],[-1,1],[1,1]], - 6: [[-1,-1],[1,-1],[-1,0],[1,0],[-1,1],[1,1]], - }[v] || []; -} - -function drawDice() { - const w = W(), h = H(); - const size = Math.min(w, h) * 0.055; - const px = w * 0.06, py = h * 0.12; - - diceAnim.t += 0.03; - if (Math.random() < 0.01) diceAnim.val = 1 + Math.floor(Math.random() * 6); - - ctx.save(); - ctx.translate(px, py); - ctx.rotate(Math.sin(diceAnim.t * 0.7) * 0.2); - - ctx.fillStyle = C.dice; - ctx.shadowColor = 'rgba(30,138,88,0.3)'; - ctx.shadowBlur = 12; - roundRect(ctx, -size / 2, -size / 2, size, size, size * 0.18); - ctx.fill(); - ctx.shadowBlur = 0; - - ctx.fillStyle = C.diceDot; - const dr = size * 0.09; - diceDots(diceAnim.val).forEach(([ddx, ddy]) => { - ctx.beginPath(); - ctx.arc(ddx * size * 0.3, ddy * size * 0.3, dr, 0, Math.PI * 2); - ctx.fill(); - }); + }); + themeObserver.observe(document.documentElement, { attributes: true }); - ctx.restore(); -} + /* โ”€โ”€ Cleanup on page unload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + window.addEventListener('beforeunload', function () { + if (mesh1) mesh1.destroy(); + if (mesh2) mesh2.destroy(); + themeObserver.disconnect(); + }); -/* โ”€โ”€ Main loop โ”€โ”€ */ -function loop() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - drawBoard(); - drawLadders(); - drawSnakes(); - tokens.forEach(t => { t.update(); t.draw(); }); - drawDice(); - requestAnimationFrame(loop); - } - - loop(); -} +})(); diff --git a/web-app/js/main.js b/web-app/js/main.js index 40c2072..f22a683 100644 --- a/web-app/js/main.js +++ b/web-app/js/main.js @@ -1,1251 +1,1153 @@ -/* - main.js โ€“ lightweight app wiring - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - CHANGES vs original (search "โ”€โ”€ PLAYGROUND" for every touched area): - 1. projectsSection reference added (to hide/show vs playground) - 2. applyCategoryFilter() guards against 'playground' category - 3. Tab click handler detects playground tab and delegates to - window.playgroundAPI (defined in playground.js) - 4. moveTabFocus() skips playground-only activation; other tabs - call deactivatePlayground automatically - 5. randomProjectBtn is hidden while playground is active - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - Everything else is 100 % identical to the original. -*/ +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + main.js โ€” App wiring for Premium Python Projects Gallery + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ function prefersReducedMotion() { - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } -// Accessibility helper referenced by modal code -function setMainInert(isInert) { - var main = document.getElementById('main-content'); - if (!main) return; - if (isInert) main.setAttribute('inert', ''); else main.removeAttribute('inert'); -} +function safeRun(fn) { try { fn(); } catch (e) { console.error(e); } } -function safeRun(fn) { - try { fn(); } catch (err) { console.error(err); } +function debounce(fn, ms) { + var timer; + return function () { + var args = arguments; var ctx = this; + clearTimeout(timer); + timer = setTimeout(function () { fn.apply(ctx, args); }, ms); + }; } -// Debounce function for smooth search performance -function debounce(func, delay) { - var timeoutId; - return function () { - var args = arguments; - clearTimeout(timeoutId); - timeoutId = setTimeout(function () { func.apply(null, args); }, delay); - }; +function syncThemeColor(theme) { + var meta = document.getElementById('themeColorMeta'); + if (meta) meta.setAttribute('content', theme === 'light' ? '#f4f6f9' : '#0c0f1a'); } -// Sync the theme-color tag with the current theme -function syncThemeColor(theme) { - var meta = document.getElementById('themeColorMeta'); - if (meta) meta.setAttribute('content', theme === 'light' ? '#fffdf8' : '#201a18'); +function escapeHtml(str) { + var d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; } +/* โ”€โ”€ DOMContentLoaded โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ document.addEventListener('DOMContentLoaded', function () { - - // Sort project cards lexicographically (alphabetically) by title - var projectsGrid = document.querySelector('.projects-grid'); - var rawCards = projectsGrid ? Array.from(projectsGrid.querySelectorAll('.project-card')) : []; - if (rawCards.length > 0) { - rawCards.sort(function (a, b) { - var h3A = a.querySelector('h3'); - var h3B = b.querySelector('h3'); - var titleA = h3A ? h3A.textContent.trim() : ''; - var titleB = h3B ? h3B.textContent.trim() : ''; - return titleA.localeCompare(titleB); - }); - var reorderedCards = document.createDocumentFragment(); - rawCards.forEach(function (card) { - reorderedCards.appendChild(card); - }); - projectsGrid.appendChild(reorderedCards); - } - - // โ”€โ”€ DOM references โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - var html = document.documentElement; - var themeToggle = document.getElementById('themeToggle'); - var soundToggle = document.getElementById('soundToggle'); - var backToTopButton = document.getElementById('backToTop'); - var tabs = document.querySelectorAll('.tab'); - var projectCards = Array.from(document.querySelectorAll('.project-card')); - var searchInput = document.getElementById('projectSearch') || document.getElementById('searchInput'); - var searchClear = document.getElementById('searchClear'); - var searchDropdown = document.getElementById('searchDropdown'); - var searchShortcut = document.getElementById('searchShortcut'); - var searchLoader = document.getElementById('searchLoader'); - var projectsSection = document.getElementById('projectsSection'); - var playgroundSection = document.getElementById('playgroundSection'); - var stickyFilterBar = document.getElementById('stickyFilterBar'); - var stickyTabs = document.querySelectorAll('.sticky-tab'); - var heroSection = document.querySelector('.hero-section'); - var cursorGlow = document.getElementById('cursorGlow'); - var heroTypewriter = document.getElementById('heroTypewriter'); - var heroProjectCount = document.getElementById('heroProjectCount'); - var heroGameCount = document.getElementById('heroGameCount'); - var heroUtilityCount = document.getElementById('heroUtilityCount'); - var revealItems = document.querySelectorAll('.reveal-on-scroll'); - var featureLaunchers = document.querySelectorAll('[data-project-target]'); - var emptyState = document.getElementById('emptyState'); - var resultsList = document.getElementById('resultsList'); - var resultsSection = document.getElementById('resultsSection'); - var recentSearchesList = document.getElementById('recentSearchesList'); - var recentSearchesSection = document.getElementById('recentSearchesSection'); - var tipsSection = document.getElementById('tipsSection'); - var noResultsMessage = document.getElementById('noResultsMessage'); - var modal = document.getElementById('projectModal'); - var modalBody = document.getElementById('modalBody'); - var modalClose = document.getElementById('modalClose'); - var modalTitle = document.getElementById('modalDialogTitle'); - /* randomProjectBtn and playgroundHeroBtn removed โ€” sidebar handles those actions */ - - - var currentCategory = 'all'; - var currentSearchQuery = ''; - var playgroundActive = false; - var selectedSuggestionIndex = -1; - var removeTrap = null; - var lastFocusedElement = null; - var recentSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]'); - //-----------------------project count badge------------------------------------ - const projectCountBadge = document.getElementById("projectCountBadge"); - const projectCount = document.querySelectorAll(".project-card").length; - const gameCount = projectCards.filter(function (card) { return card.getAttribute('data-category') === 'games'; }).length; - const utilityCount = projectCards.filter(function (card) { return card.getAttribute('data-category') === 'utilities'; }).length; - - if (projectCountBadge) { - projectCountBadge.textContent = `${projectCount} projects`; - } - if (heroProjectCount) heroProjectCount.textContent = String(projectCount); - if (heroGameCount) heroGameCount.textContent = String(gameCount); - if (heroUtilityCount) heroUtilityCount.textContent = String(utilityCount); - - var rotatePhrases = [ - 'typing speed drills', - 'math quiz rounds', - 'logic puzzle practice', - 'browser-ready Python wins' - ]; - - if (heroTypewriter && !prefersReducedMotion()) { - var phraseIndex = 0; - setInterval(function () { - phraseIndex = (phraseIndex + 1) % rotatePhrases.length; - heroTypewriter.textContent = rotatePhrases[phraseIndex]; - }, 2200); - } - - if (cursorGlow && !prefersReducedMotion()) { - document.addEventListener('pointermove', function (event) { - cursorGlow.style.left = event.clientX + 'px'; - cursorGlow.style.top = event.clientY + 'px'; - }); - document.addEventListener('pointerleave', function () { - cursorGlow.style.opacity = '0'; - }); - document.addEventListener('pointerenter', function () { - cursorGlow.style.opacity = '0.5'; - }); - } - // โ”€โ”€ Theme Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function updateThemeToggleAria(isLightTheme) { - if (!themeToggle) return; - themeToggle.setAttribute( - 'aria-label', - isLightTheme ? 'Switch to dark mode' : 'Switch to light mode' - ); - } - - if (themeToggle) { - var savedTheme = localStorage.getItem('theme') || 'light'; - html.setAttribute('data-theme', savedTheme); - syncThemeColor(savedTheme); - themeToggle.innerHTML = - savedTheme === 'light' - ? '' - : ''; - updateThemeToggleAria(savedTheme === 'light'); - - themeToggle.addEventListener('click', function () { - var currentTheme = html.getAttribute('data-theme'); - var newTheme = currentTheme === 'light' ? 'dark' : 'light'; - - html.setAttribute('data-theme', newTheme); - localStorage.setItem('theme', newTheme); - syncThemeColor(newTheme); - - themeToggle.innerHTML = - newTheme === 'light' - ? '' - : ''; - updateThemeToggleAria(newTheme === 'light'); - }); + var html = document.documentElement; + var themeToggle = document.getElementById('themeToggle'); + var soundToggle = document.getElementById('soundToggle'); + var backToTopButton = document.getElementById('backToTop'); + var searchInput = document.getElementById('searchInput'); + var searchDropdown = document.getElementById('searchDropdown'); + var searchLoader = document.getElementById('searchLoader'); + var recentSearchesList = document.getElementById('recentSearchesList'); + var recentSearchesSection = document.getElementById('recentSearchesSection'); + var resultsList = document.getElementById('resultsList'); + var resultsSection = document.getElementById('resultsSection'); + var tipsSection = document.getElementById('tipsSection'); + var noResultsMessage = document.getElementById('noResultsMessage'); + var projectsSection = document.getElementById('projectsSection'); + var playgroundSection = document.getElementById('playgroundSection'); + var stickyFilterBar = document.getElementById('stickyFilterBar'); + var stickyTabs = document.querySelectorAll('.sticky-tab'); + var heroSection = document.querySelector('.hero-section'); + var cursorGlow = document.getElementById('cursorGlow'); + var heroProjectCount = document.getElementById('heroProjectCount'); + var heroGameCount = document.getElementById('heroGameCount'); + var heroUtilityCount = document.getElementById('heroUtilityCount'); + var modal = document.getElementById('projectModal'); + var modalBody = document.getElementById('modalBody'); + var modalClose = document.getElementById('modalClose'); + var modalTitle = document.getElementById('modalDialogTitle'); + var exploreBtn = document.getElementById('exploreBtn'); + var randomProjectBtn = document.getElementById('randomProjectBtn'); + var randomProjectBtnSidebar = document.getElementById('randomProjectBtnSidebar'); + var emptyState = document.getElementById('emptyState'); + var emptyStateHint = document.getElementById('emptyStateHint'); + var projectCountBadge = document.getElementById('projectCountBadge'); + var mobileMenuToggle = document.getElementById('mobileMenuToggle'); + var navControls = document.getElementById('navControls'); + var navbar = document.getElementById('mainNavbar'); + + var currentCategory = 'all'; + var currentSearchQuery = ''; + var playgroundActive = false; + var selectedSuggestionIndex = -1; + var removeTrap = null; + var lastFocusedElement = null; + var projectCards = []; + var recentSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]'); + + /* โ”€โ”€ Helper: setMainInert โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function setMainInert(isInert) { + var main = document.getElementById('main-content'); + if (!main) return; + if (isInert) main.setAttribute('inert', ''); else main.removeAttribute('inert'); + } + + /* โ”€โ”€ Theme Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function updateThemeToggleAria(isLight) { + if (!themeToggle) return; + themeToggle.setAttribute('aria-label', isLight ? 'Switch to dark mode' : 'Switch to light mode'); + } + + if (themeToggle) { + var savedTheme = localStorage.getItem('theme') || 'dark'; + html.setAttribute('data-theme', savedTheme); + syncThemeColor(savedTheme); + themeToggle.innerHTML = savedTheme === 'light' + ? '' + : ''; + updateThemeToggleAria(savedTheme === 'light'); + + themeToggle.addEventListener('click', function () { + var current = html.getAttribute('data-theme'); + var next = current === 'light' ? 'dark' : 'light'; + html.setAttribute('data-theme', next); + localStorage.setItem('theme', next); + syncThemeColor(next); + themeToggle.innerHTML = next === 'light' + ? '' + : ''; + updateThemeToggleAria(next === 'light'); + }); + } + + /* โ”€โ”€ Sound Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + if (soundToggle && window.audioController) { + function updateSoundIcon() { + soundToggle.innerHTML = window.audioController.isMuted + ? '' + : ''; } - - // โ”€โ”€ Sound Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (soundToggle) { - var updateSoundIcon = function () { - if (window.audioController) { - soundToggle.innerHTML = window.audioController.isMuted - ? '' - : ''; - } - }; + updateSoundIcon(); + soundToggle.addEventListener('click', function () { + if (typeof window.audioController.toggleMute === 'function') { + window.audioController.toggleMute(); updateSoundIcon(); - soundToggle.addEventListener('click', function () { - if (window.audioController && typeof window.audioController.toggleMute === 'function') { - window.audioController.toggleMute(); - updateSoundIcon(); - if (!window.audioController.isMuted && typeof window.audioController.play === 'function') { - window.audioController.play('click'); - } - } - }); - } - - // โ”€โ”€ Back to Top โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (backToTopButton) { - var toggleBackToTopButton = function () { - backToTopButton.classList.toggle('visible', window.scrollY > 300); - var navbar = document.querySelector('.navbar'); - if (navbar) navbar.classList.toggle('scrolled', window.scrollY > 12); - }; - window.addEventListener('scroll', toggleBackToTopButton, { passive: true }); - toggleBackToTopButton(); - - backToTopButton.addEventListener('click', function () { - window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); - }); - } - - // โ”€โ”€ PLAYGROUND: helpers to show / hide sections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /* โ† PLAYGROUND ADD (entire block) */ - function showProjectsSection() { - playgroundActive = false; - if (playgroundSection) playgroundSection.style.display = 'none'; - if (projectsSection) projectsSection.style.display = ''; - if (window.playgroundAPI && typeof window.playgroundAPI.deactivate === 'function') { - window.playgroundAPI.deactivate(); + if (!window.audioController.isMuted && typeof window.audioController.play === 'function') { + window.audioController.play('click'); } - } - - function showPlaygroundSection() { - playgroundActive = true; - syncStickyTabs('playground'); - if (projectsSection) projectsSection.style.display = 'none'; - if (window.playgroundAPI && typeof window.playgroundAPI.activate === 'function') { - window.playgroundAPI.activate(); - } - } - /* โ† PLAYGROUND ADD end */ - // โ”€โ”€ Sticky Filter Bar: position + show/hide on scroll โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function syncStickyTabs(category) { - stickyTabs.forEach(function (st) { - var selected = st.getAttribute('data-sticky-category') === category; - st.classList.toggle('active', selected); - st.setAttribute('aria-selected', selected ? 'true' : 'false'); - st.setAttribute('tabindex', selected ? '0' : '-1'); + } }); -} - -if (stickyFilterBar && heroSection) { - // Position the bar directly below the navbar - var navbar = document.querySelector('.navbar'); - function positionStickyBar() { - var navHeight = navbar ? navbar.getBoundingClientRect().height : 0; - stickyFilterBar.style.top = navHeight + 'px'; - } - positionStickyBar(); - window.addEventListener('resize', positionStickyBar); - - var heroObserver = new IntersectionObserver(function (entries) { - entries.forEach(function (entry) { - stickyFilterBar.classList.toggle('visible', !entry.isIntersecting); - }); - }, { threshold: 0 }); - heroObserver.observe(heroSection); -} - - // Wire sticky tab clicks โ€” mirrors main tab behaviour - stickyTabs.forEach(function (st) { - st.addEventListener('click', function () { - var category = st.getAttribute('data-sticky-category'); - - // Sync sticky tabs UI - syncStickyTabs(category); - - // Sync hero tabs UI - tabs.forEach(function (t) { - var selected = t.getAttribute('data-category') === category; - t.classList.toggle('active', selected); - t.setAttribute('aria-selected', selected ? 'true' : 'false'); - t.setAttribute('tabindex', selected ? '0' : '-1'); - }); - - // Delegate section logic (same as hero tab click) - if (category === 'playground') { - showPlaygroundSection(); - } else { - showProjectsSection(); - applyCategoryFilter(category); - } - }); + } else if (soundToggle) { + soundToggle.addEventListener('click', function () { + var icon = soundToggle.querySelector('i'); + if (icon) icon.className = icon.className === 'fas fa-volume-up' ? 'fas fa-volume-mute' : 'fas fa-volume-up'; }); - - // โ”€โ”€ Category Filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function applyCategoryFilter(category) { - /* โ”€โ”€ PLAYGROUND ADD: skip filtering when playground tab is selected โ”€โ”€ */ - if (category === 'playground') return; - /* โ”€โ”€ PLAYGROUND ADD end โ”€โ”€ */ - - currentCategory = category; - syncStickyTabs(category); - var visibleCount = 0; - var favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); - projectCards.forEach(function (card) { - var cardCategory = card.getAttribute('data-category'); - var projectName = card.getAttribute('data-project'); - var isFavorite = favorites.includes(projectName); - - if (category === 'all' || - (category === 'favorites' && isFavorite) || - (category !== 'favorites' && cardCategory === category)) { - card.style.display = ''; - card.style.animation = prefersReducedMotion() ? 'none' : 'fadeIn 0.6s ease'; - visibleCount++; - } else { - card.style.display = 'none'; - } - }); - - if (emptyState) { - emptyState.style.display = visibleCount === 0 ? 'block' : 'none'; - } - if (sidebarBadge) { - sidebarBadge.textContent = String(visibleCount); - } + } + + /* โ”€โ”€ Hero Controls Mirror Toggles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var heroSoundToggle = document.getElementById('heroSoundToggle'); + var heroThemeToggle = document.getElementById('heroThemeToggle'); + + function syncHeroControlsIcons() { + if (heroSoundToggle && soundToggle) { + var realSoundIcon = soundToggle.querySelector('i'); + var heroSoundIcon = heroSoundToggle.querySelector('i'); + if (realSoundIcon && heroSoundIcon) { + heroSoundIcon.className = realSoundIcon.className; + } } - - function moveTabFocus(fromIndex, delta) { - var len = tabs.length; - var next = (fromIndex + delta + len) % len; - tabs.forEach(function (t, i) { - var selected = i === next; - t.classList.toggle('active', selected); - t.setAttribute('aria-selected', selected ? 'true' : 'false'); - t.setAttribute('tabindex', selected ? '0' : '-1'); - }); - tabs[next].focus(); - - /* โ”€โ”€ PLAYGROUND ADD: delegate to section helpers โ”€โ”€ */ - var nextCategory = tabs[next].getAttribute('data-category'); - if (nextCategory === 'playground') { - showPlaygroundSection(); - } else { - showProjectsSection(); - applyCategoryFilter(nextCategory); - } - /* โ”€โ”€ PLAYGROUND ADD end โ”€โ”€ */ + if (heroThemeToggle && themeToggle) { + var realThemeIcon = themeToggle.querySelector('i'); + var heroThemeIcon = heroThemeToggle.querySelector('i'); + if (realThemeIcon && heroThemeIcon) { + heroThemeIcon.className = realThemeIcon.className; + } } + } - tabs.forEach(function (tab, index) { - tab.addEventListener('click', function () { - /* Update active state on all tabs */ - tabs.forEach(function (t) { - var selected = t === tab; - t.classList.toggle('active', selected); - t.setAttribute('aria-selected', selected ? 'true' : 'false'); - t.setAttribute('tabindex', selected ? '0' : '-1'); - }); - - var category = tab.getAttribute('data-category'); - - /* โ”€โ”€ PLAYGROUND ADD: playground tab gets its own section โ”€โ”€ */ - if (category === 'playground') { - showPlaygroundSection(); - } else { - showProjectsSection(); - applyCategoryFilter(category); - } - /* โ”€โ”€ PLAYGROUND ADD end โ”€โ”€ */ - }); - - tab.addEventListener('keydown', function (e) { - var handled = false; - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - moveTabFocus(index, 1); - handled = true; - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - moveTabFocus(index, -1); - handled = true; - } else if (e.key === 'Home') { - moveTabFocus(index, -index); - handled = true; - } else if (e.key === 'End') { - moveTabFocus(index, tabs.length - 1 - index); - handled = true; - } - if (handled) e.preventDefault(); - }); + if (heroSoundToggle && soundToggle) { + heroSoundToggle.addEventListener('click', function () { + soundToggle.click(); + setTimeout(syncHeroControlsIcons, 50); }); + } - // โ”€โ”€ Sidebar Filter Tabs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - var sidebarTabs = document.querySelectorAll('.sidebar-tab'); - var sidebarBadge = document.getElementById('sidebarProjectCount'); - var sidebarRandomBtn = document.getElementById('randomProjectBtnSidebar'); - - function syncSidebarTabs(category) { - sidebarTabs.forEach(function (st) { - var selected = st.getAttribute('data-category') === category; - st.classList.toggle('active', selected); - st.setAttribute('aria-selected', selected ? 'true' : 'false'); - st.setAttribute('tabindex', selected ? '0' : '-1'); - }); - if (sidebarBadge) { - var visible = projectCards.filter(function (c) { return c.style.display !== 'none'; }).length; - sidebarBadge.textContent = String(visible); - } - } - - sidebarTabs.forEach(function (st) { - st.addEventListener('click', function () { - var category = st.getAttribute('data-category'); - syncSidebarTabs(category); - syncStickyTabs(category); - - if (category === 'playground') { - showPlaygroundSection(); - var pg = document.getElementById('playgroundSection'); - if (pg) pg.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } else { - showProjectsSection(); - applyCategoryFilter(category); - if (projectsSection) { - projectsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - }); + if (heroThemeToggle && themeToggle) { + heroThemeToggle.addEventListener('click', function () { + themeToggle.click(); + setTimeout(syncHeroControlsIcons, 50); + }); + } + + // Initial sync on load + setTimeout(syncHeroControlsIcons, 100); + + /* โ”€โ”€ Mobile Sidebar Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var mobileSidebarToggle = document.getElementById('mobileSidebarToggle'); + var mainSidebar = document.getElementById('mainSidebar'); + if (mobileSidebarToggle && mainSidebar) { + mobileSidebarToggle.addEventListener('click', function () { + var active = mainSidebar.classList.toggle('open'); + mobileSidebarToggle.setAttribute('aria-expanded', active); + var icon = mobileSidebarToggle.querySelector('i'); + if (icon) icon.className = active ? 'fas fa-times' : 'fas fa-bars'; }); - if (sidebarRandomBtn) { - sidebarRandomBtn.addEventListener('click', function () { - var visible = projectCards.filter(function (c) { return c.style.display !== 'none'; }); - var pool = visible.length ? visible : projectCards; - var pick = pool[Math.floor(Math.random() * pool.length)]; - var name = pick.getAttribute('data-project'); - if (name && typeof openProjectSafe === 'function') { - openProjectSafe(name, sidebarRandomBtn); - setTimeout(function () { - pick.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 300); - } - }); - } - - // โ”€โ”€ Search / Autocomplete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function getMatchingProjects(query) { - if (!query) return []; - var matches = []; - projectCards.forEach(function (card) { - var category = card.getAttribute('data-category'); - var title = card.querySelector('h3').textContent.toLowerCase(); - var description = card.querySelector('p').textContent.toLowerCase(); - var tags = (card.getAttribute('data-tags') || '').toLowerCase(); - - var categoryMatch = currentCategory === 'all' || category === currentCategory; - var searchMatch = title.includes(query) || - description.includes(query) || - tags.includes(query); - - if (categoryMatch && searchMatch) { - matches.push({ - card: card, - title: card.querySelector('h3').textContent, - tags: card.getAttribute('data-tags') || '', - category: category - }); - } - }); - return matches; - } + document.addEventListener('click', function (e) { + if (mainSidebar && mobileSidebarToggle && + !mainSidebar.contains(e.target) && e.target !== mobileSidebarToggle && + mainSidebar.classList.contains('open')) { + mainSidebar.classList.remove('open'); + mobileSidebarToggle.setAttribute('aria-expanded', 'false'); + var icon = mobileSidebarToggle.querySelector('i'); + if (icon) icon.className = 'fas fa-bars'; + } + }); + } - function highlightText(container, text, query) { - var safeQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - var chunks = text.split(new RegExp('(' + safeQuery + ')', 'gi')); - - chunks.forEach(function (part) { - if (part.toLowerCase() === query.toLowerCase()) { - var highlight = document.createElement('mark'); - highlight.style.background = 'rgba(99, 102, 241, 0.3)'; - highlight.style.color = 'var(--primary-color)'; - highlight.style.fontWeight = '600'; - highlight.textContent = part; - container.appendChild(highlight); - } else if (part) { - container.appendChild(document.createTextNode(part)); - } - }); - } + if (backToTopButton) { + var toggleBackToTop = function () { + backToTopButton.classList.toggle('visible', window.scrollY > 300); + }; + window.addEventListener('scroll', toggleBackToTop, { passive: true }); + toggleBackToTop(); - function updateSuggestionHighlight() { - if (!resultsList) return; - var items = resultsList.querySelectorAll('.dropdown-item'); - items.forEach(function (item, i) { - item.classList.toggle('selected', i === selectedSuggestionIndex); - }); + backToTopButton.addEventListener('click', function () { + window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + }); + } + + /* โ”€โ”€ Cursor Glow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + if (cursorGlow && !prefersReducedMotion() && html.getAttribute('data-theme') !== 'light') { + var glowTimeout; + document.addEventListener('pointermove', function (e) { + cursorGlow.style.left = e.clientX + 'px'; + cursorGlow.style.top = e.clientY + 'px'; + cursorGlow.style.opacity = '0.5'; + clearTimeout(glowTimeout); + glowTimeout = setTimeout(function () { cursorGlow.style.opacity = '0'; }, 3000); + }); + document.addEventListener('pointerleave', function () { + cursorGlow.style.opacity = '0'; + }); + } + + /* โ”€โ”€ Gather Project Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var projectsGrid = document.querySelector('.projects-grid'); + projectCards = Array.from(document.querySelectorAll('.project-card')); + + /* โ”€โ”€ Hero Stats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var totalCount = projectCards.length; + var gameCount = projectCards.filter(function (c) { return c.getAttribute('data-category') === 'games'; }).length; + var utilityCount = projectCards.filter(function (c) { return c.getAttribute('data-category') === 'utilities'; }).length; + + if (heroProjectCount) heroProjectCount.textContent = String(totalCount); + if (heroGameCount) heroGameCount.textContent = String(gameCount); + if (heroUtilityCount) heroUtilityCount.textContent = String(utilityCount); + if (projectCountBadge) projectCountBadge.textContent = String(totalCount) + ' projects'; + + /* โ”€โ”€ Explore Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + if (exploreBtn) { + exploreBtn.addEventListener('click', function () { + if (projectsSection) projectsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + + /* โ”€โ”€ Category Filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var sidebarTabs = document.querySelectorAll('.sidebar-tab'); + var sidebarBadge = null; + + function applyCategoryFilter(category) { + if (category === 'playground') return; + currentCategory = category; + syncSidebarTabs(category); + syncStickyTabs(category); + var visibleCount = 0; + var favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); + projectCards.forEach(function (card) { + var cardCat = card.getAttribute('data-category'); + var projectName = card.getAttribute('data-project'); + var isFav = favorites.includes(projectName); + if (category === 'all' || + (category === 'favorites' && isFav) || + (category !== 'favorites' && cardCat === category)) { + card.style.display = ''; + visibleCount++; + } else { + card.style.display = 'none'; + } + }); + if (emptyState) { + emptyState.style.display = visibleCount === 0 ? 'block' : 'none'; } - - function selectSuggestion(title) { - if (!searchInput) return; - searchInput.value = title; - currentSearchQuery = title.toLowerCase(); - performSearch(); - closeDropdown(); - if (projectsSection) { - projectsSection.scrollIntoView({ - behavior: prefersReducedMotion() ? 'auto' : 'smooth', - block: 'start' - }); - } + if (projectCountBadge) { + projectCountBadge.textContent = String(visibleCount) + ' projects'; } + } - function cleanSearchHighlights() { - projectCards.forEach(function (card) { - var titleEl = card.querySelector('h3'); - var descEl = card.querySelector('p'); - [titleEl, descEl].forEach(function (el) { - if (!el) return; - el.querySelectorAll('mark.search-highlight').forEach(function (m) { - m.replaceWith(m.textContent); - }); - }); - }); - } + function syncSidebarTabs(category) { + sidebarTabs.forEach(function (st) { + var selected = st.getAttribute('data-category') === category; + st.classList.toggle('active', selected); + st.setAttribute('aria-selected', selected ? 'true' : 'false'); + st.setAttribute('tabindex', selected ? '0' : '-1'); + }); + } - function escapeHtml(str) { - var div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; + function syncStickyTabs(category) { + stickyTabs.forEach(function (st) { + var selected = st.getAttribute('data-sticky-category') === category; + st.classList.toggle('active', selected); + st.setAttribute('aria-selected', selected ? 'true' : 'false'); + st.setAttribute('tabindex', selected ? '0' : '-1'); + }); + } + + /* โ”€โ”€ Playground Section Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function showProjectsSection() { + playgroundActive = false; + if (playgroundSection) playgroundSection.style.display = 'none'; + if (projectsSection) projectsSection.style.display = ''; + if (window.playgroundAPI && typeof window.playgroundAPI.deactivate === 'function') { + window.playgroundAPI.deactivate(); } - - function highlightCardText(el, query) { - var safe = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - var regex = new RegExp('(' + safe + ')', 'gi'); - var escaped = escapeHtml(el.textContent); - var html = escaped.replace(regex, '$1'); - el.innerHTML = html; + } + + function showPlaygroundSection() { + playgroundActive = true; + syncStickyTabs('playground'); + if (projectsSection) projectsSection.style.display = 'none'; + if (playgroundSection) { + playgroundSection.style.display = ''; + if (window.playgroundAPI && typeof window.playgroundAPI.activate === 'function') { + window.playgroundAPI.activate(); + } } - - function performSearch() { - var query = currentSearchQuery; - cleanSearchHighlights(); - - if (!query) { - applyCategoryFilter(currentCategory); - updateSearchResultCount(0); - var hint = document.getElementById('emptyStateHint'); - if (hint) hint.textContent = 'Try adjusting your search or category filter.'; - return; - } - - if (currentCategory !== 'all') { - currentCategory = 'all'; - syncSidebarTabs('all'); - syncStickyTabs('all'); - } - - recentSearches = recentSearches.filter(function (s) { return s !== query; }); - recentSearches.unshift(query); - recentSearches = recentSearches.slice(0, 10); - localStorage.setItem('recentSearches', JSON.stringify(recentSearches)); - - var visibleCount = 0; - var favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); - projectCards.forEach(function (card) { - var category = card.getAttribute('data-category'); - var titleText = (card.querySelector('h3') || {}).textContent || ''; - var descText = (card.querySelector('p') || {}).textContent || ''; - var title = titleText.toLowerCase(); - var description = descText.toLowerCase(); - var tags = (card.getAttribute('data-tags') || '').toLowerCase(); - var projectName = card.getAttribute('data-project'); - var isFavorite = favorites.includes(projectName); - - var categoryMatch = currentCategory === 'all' || - (currentCategory === 'favorites' && isFavorite) || - (currentCategory !== 'favorites' && category === currentCategory); - var searchMatch = title.includes(query) || - description.includes(query) || - tags.includes(query); - - if (categoryMatch && searchMatch) { - card.style.display = ''; - card.style.animation = prefersReducedMotion() ? 'none' : 'fadeIn 0.5s ease'; - visibleCount++; - var titleEl = card.querySelector('h3'); - var descEl = card.querySelector('p'); - if (titleEl) highlightCardText(titleEl, query); - if (descEl) highlightCardText(descEl, query); - } else { - card.style.display = 'none'; - } - }); - - if (emptyState) { - var showEmpty = visibleCount === 0; - emptyState.style.display = showEmpty ? 'block' : 'none'; - if (showEmpty) { - var hint = document.getElementById('emptyStateHint'); - if (hint) hint.textContent = 'No projects match "' + query + '". Try a different keyword.'; - } + } + + /* โ”€โ”€ Sidebar Tabs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + sidebarTabs.forEach(function (st) { + st.addEventListener('click', function () { + var category = st.getAttribute('data-category'); + + var pageCategory = document.body.getAttribute('data-page'); + if (pageCategory) { + // We are on a subpage (games, math, or utilities) + if (category === pageCategory) { + var grid = document.getElementById('projectsGrid'); + if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; } + var pageMap = { + 'all': 'index.html', + 'games': 'games.html', + 'math': 'math.html', + 'utilities': 'utilities.html', + 'favorites': 'index.html?category=favorites', + 'playground': 'index.html?category=playground' + }; + window.location.href = pageMap[category] || 'index.html'; + return; + } + + syncSidebarTabs(category); + syncStickyTabs(category); + + if (category === 'playground') { + showPlaygroundSection(); + if (playgroundSection) playgroundSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else { + showProjectsSection(); + applyCategoryFilter(category); + if (projectsSection) projectsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + /* โ”€โ”€ Sticky Tabs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + stickyTabs.forEach(function (st) { + st.addEventListener('click', function () { + var category = st.getAttribute('data-sticky-category'); + syncStickyTabs(category); + syncSidebarTabs(category); + + if (category === 'playground') { + showPlaygroundSection(); + if (playgroundSection) playgroundSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else { + showProjectsSection(); + applyCategoryFilter(category); + if (projectsSection) projectsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); - updateSearchResultCount(visibleCount); - } + /* โ”€โ”€ Sticky Filter Bar Visibility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + if (stickyFilterBar && heroSection) { + var heroObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + stickyFilterBar.classList.toggle('visible', !entry.isIntersecting); + }); + }, { threshold: 0, rootMargin: '-80px 0px 0px 0px' }); + heroObserver.observe(heroSection); - function updateSearchResultCount(count) { - var badge = document.getElementById('searchResultCount'); - if (!badge) { - if (!searchInput) return; - badge = document.createElement('span'); - badge.id = 'searchResultCount'; - badge.style.cssText = 'font-family:IBM Plex Mono,monospace;font-size:0.72rem;color:var(--text-secondary);margin-left:0.5rem;white-space:nowrap;opacity:0;transition:opacity 0.2s ease'; - searchInput.parentNode.appendChild(badge); - } - if (count > 0) { - badge.textContent = count + ' result' + (count !== 1 ? 's' : ''); - badge.style.opacity = '1'; - } else { - badge.style.opacity = '0'; - } + window.addEventListener('scroll', function () { + var navH = navbar ? navbar.getBoundingClientRect().height : 72; + stickyFilterBar.style.top = (navH + 16) + 'px'; + }, { passive: true }); + } + + /* โ”€โ”€ Random Project โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function openRandomProject(trigger) { + var visible = projectCards.filter(function (c) { return c.style.display !== 'none'; }); + var pool = visible.length ? visible : projectCards; + var pick = pool[Math.floor(Math.random() * pool.length)]; + var name = pick.getAttribute('data-project'); + if (name && typeof openProjectSafe === 'function') { + openProjectSafe(name, trigger); } - - function closeDropdown() { - if (searchDropdown) searchDropdown.classList.remove('active'); + } + + if (randomProjectBtn) { + randomProjectBtn.addEventListener('click', function () { openRandomProject(randomProjectBtn); }); + } + if (randomProjectBtnSidebar) { + randomProjectBtnSidebar.addEventListener('click', function () { openRandomProject(randomProjectBtnSidebar); }); + } + + /* โ”€โ”€ Init sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var pageCategory = document.body.getAttribute('data-page'); + if (sidebarTabs.length) { + if (pageCategory) { + syncSidebarTabs(pageCategory); + } else { + syncSidebarTabs('all'); } + } + if (stickyTabs.length) syncStickyTabs('all'); + + /* โ”€โ”€ Sidebar Active Scroll Observer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + if (!pageCategory && projectsSection) { + // On homepage, observe projectsSection to toggle sidebar-active class + var sidebarActiveObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + var isVisible = entry.isIntersecting || entry.boundingClientRect.top < 200; + document.body.classList.toggle('sidebar-active', isVisible); + }); + }, { threshold: 0.05 }); + sidebarActiveObserver.observe(projectsSection); + } else if (pageCategory) { + // On subpages, always ensure sidebar is active + document.body.classList.add('sidebar-active'); + } + + /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + SEARCH + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + function getMatchingProjects(query) { + if (!query) return []; + var matches = []; + projectCards.forEach(function (card) { + var category = card.getAttribute('data-category'); + var title = (card.querySelector('h3') || {}).textContent || ''; + var desc = (card.querySelector('p') || {}).textContent || ''; + var tags = (card.getAttribute('data-tags') || '').toLowerCase(); + var q = query.toLowerCase(); + + var catMatch = currentCategory === 'all' || category === currentCategory; + var searchMatch = title.toLowerCase().includes(q) || + desc.toLowerCase().includes(q) || + tags.includes(q); + + if (catMatch && searchMatch) { + matches.push({ card: card, title: title, tags: tags, category: category }); + } + }); + return matches; + } + + function highlightText(container, text, query) { + var safe = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + var parts = text.split(new RegExp('(' + safe + ')', 'gi')); + parts.forEach(function (part) { + if (part.toLowerCase() === query.toLowerCase()) { + var mark = document.createElement('mark'); + mark.style.background = 'var(--accent-soft)'; + mark.style.color = 'var(--accent)'; + mark.style.fontWeight = '600'; + mark.style.borderRadius = '2px'; + mark.style.padding = '0 2px'; + mark.textContent = part; + container.appendChild(mark); + } else if (part) { + container.appendChild(document.createTextNode(part)); + } + }); + } - function renderRecentSearches() { - if (noResultsMessage) noResultsMessage.style.display = 'none'; - if (!recentSearchesSection) return; - - if (recentSearches.length === 0) { - recentSearchesSection.style.display = 'none'; - if (tipsSection) tipsSection.style.display = 'block'; - if (resultsSection) resultsSection.style.display = 'none'; - return; - } + function closeDropdown() { + if (searchDropdown) searchDropdown.classList.remove('active'); + } - if (recentSearchesList) { - recentSearchesList.innerHTML = ''; - recentSearches.slice(0, 5).forEach(function (search) { - var item = document.createElement('div'); - item.className = 'dropdown-recent-item'; - var recentText = document.createElement('div'); - recentText.className = 'dropdown-recent-text'; - - var clockIcon = document.createElement('i'); - clockIcon.className = 'fas fa-history'; - clockIcon.style.opacity = '0.5'; - clockIcon.style.fontSize = '0.9rem'; - - var searchLabel = document.createElement('span'); - searchLabel.style.flex = '1'; - searchLabel.style.cursor = 'pointer'; - searchLabel.style.color = 'var(--text-secondary)'; - searchLabel.textContent = search; - - recentText.append(clockIcon, searchLabel); - - var removeButton = document.createElement('button'); - removeButton.className = 'dropdown-recent-remove'; - removeButton.setAttribute('aria-label', 'Remove search'); - - var removeIcon = document.createElement('i'); - removeIcon.className = 'fas fa-x'; - removeButton.appendChild(removeIcon); - - item.append(recentText, removeButton); - - searchLabel.addEventListener('click', function () { - if (searchInput) { - searchInput.value = search; - currentSearchQuery = search; - performSearch(); - closeDropdown(); - } - }); - - removeButton.addEventListener('click', function (e) { - e.stopPropagation(); - recentSearches = recentSearches.filter(function (s) { return s !== search; }); - localStorage.setItem('recentSearches', JSON.stringify(recentSearches)); - renderRecentSearches(); - }); - - recentSearchesList.appendChild(item); - }); - } + function renderRecentSearches() { + if (noResultsMessage) noResultsMessage.style.display = 'none'; + if (!recentSearchesSection) return; - recentSearchesSection.style.display = 'block'; - if (resultsSection) resultsSection.style.display = 'none'; - if (tipsSection) tipsSection.style.display = 'block'; + if (recentSearches.length === 0) { + recentSearchesSection.style.display = 'none'; + if (tipsSection) tipsSection.style.display = 'block'; + if (resultsSection) resultsSection.style.display = 'none'; + return; } - function renderSuggestions(query) { - if (searchLoader) searchLoader.style.display = 'none'; - if (!query) { renderRecentSearches(); return; } + if (recentSearchesList) { + recentSearchesList.innerHTML = ''; + recentSearches.slice(0, 5).forEach(function (search) { + var item = document.createElement('div'); + item.className = 'dropdown-recent-item'; + var text = document.createElement('div'); + text.className = 'dropdown-recent-text'; - var matches = getMatchingProjects(query); + var clock = document.createElement('i'); + clock.className = 'fas fa-history'; + clock.style.opacity = '0.5'; + clock.style.fontSize = '0.8rem'; - if (matches.length === 0) { - if (resultsSection) resultsSection.style.display = 'none'; - if (recentSearchesSection) recentSearchesSection.style.display = 'none'; - if (tipsSection) tipsSection.style.display = 'block'; - if (noResultsMessage) noResultsMessage.style.display = 'block'; - return; - } - - if (noResultsMessage) noResultsMessage.style.display = 'none'; - - if (resultsList) { - resultsList.innerHTML = ''; - matches.slice(0, 8).forEach(function (project, index) { - var item = document.createElement('div'); - item.className = 'dropdown-item' + (index === selectedSuggestionIndex ? ' selected' : ''); - var bannerEl = project.card.querySelector('.card-banner'); - var iconBox = document.createElement('div'); - iconBox.className = 'dropdown-item-icon'; - if (bannerEl) { - var img = document.createElement('img'); - img.src = bannerEl.src; - img.alt = ''; - img.style.cssText = 'width:24px;height:24px;border-radius:4px;object-fit:cover'; - iconBox.appendChild(img); - } - - var titleBox = document.createElement('div'); - titleBox.className = 'dropdown-item-text'; - highlightText(titleBox, project.title, query); - - var categoryTag = document.createElement('span'); - categoryTag.className = 'dropdown-item-tag'; - categoryTag.textContent = project.category; - - item.append(iconBox, titleBox, categoryTag); - item.addEventListener('click', function () { selectSuggestion(project.title); }); - item.addEventListener('mouseenter', function () { - selectedSuggestionIndex = index; - updateSuggestionHighlight(); - }); - resultsList.appendChild(item); - }); - } + var label = document.createElement('span'); + label.textContent = search; - if (resultsSection) resultsSection.style.display = 'block'; - if (recentSearchesSection) recentSearchesSection.style.display = 'none'; - if (tipsSection) tipsSection.style.display = 'none'; - selectedSuggestionIndex = -1; - } + text.append(clock, label); - if (searchInput) { - var debouncedSearch = debounce(function (query) { - renderSuggestions(query); - }, 200); - - searchInput.addEventListener('input', function (e) { - var query = e.target.value.trim().toLowerCase(); - currentSearchQuery = query; - if (searchClear) searchClear.style.display = query ? 'flex' : 'none'; - if (searchLoader) searchLoader.style.display = query ? 'block' : 'none'; - debouncedSearch(query); - performSearch(); - if (query && projectsSection && document.activeElement === searchInput) { - projectsSection.scrollIntoView({ - behavior: prefersReducedMotion() ? 'auto' : 'smooth', - block: 'start' - }); - } - }); + var removeBtn = document.createElement('button'); + removeBtn.className = 'dropdown-recent-remove'; + removeBtn.setAttribute('aria-label', 'Remove search'); + removeBtn.innerHTML = ''; - searchInput.addEventListener('focus', function () { - if (searchDropdown) searchDropdown.classList.add('active'); - if (searchShortcut) searchShortcut.style.display = 'none'; - if (!currentSearchQuery) renderRecentSearches(); - }); + item.append(text, removeBtn); - searchInput.addEventListener('keydown', function (e) { - if (e.key === 'Escape') { closeDropdown(); searchInput.blur(); } + label.addEventListener('click', function () { + if (searchInput) searchInput.value = search; + currentSearchQuery = search; + performSearch(); + closeDropdown(); }); - } - if (searchClear) { - searchClear.addEventListener('click', function () { - if (searchInput) searchInput.value = ''; - currentSearchQuery = ''; - searchClear.style.display = 'none'; - if (searchLoader) searchLoader.style.display = 'none'; - cleanSearchHighlights(); - updateSearchResultCount(0); - var hint = document.getElementById('emptyStateHint'); - if (hint) hint.textContent = 'Try adjusting your search or category filter.'; - applyCategoryFilter(currentCategory); - closeDropdown(); + removeBtn.addEventListener('click', function (e) { + e.stopPropagation(); + recentSearches = recentSearches.filter(function (s) { return s !== search; }); + localStorage.setItem('recentSearches', JSON.stringify(recentSearches)); + renderRecentSearches(); }); - } - - document.addEventListener('click', function (e) { - if (searchDropdown && searchInput && - !searchDropdown.contains(e.target) && e.target !== searchInput) { - closeDropdown(); - } - }); - - document.addEventListener('keydown', function (e) { - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - e.preventDefault(); - if (searchInput) searchInput.focus(); - } - }); - - renderRecentSearches(); - // โ”€โ”€ Init sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (sidebarTabs.length) syncSidebarTabs('all'); - - // โ”€โ”€ Central Dynamic Auto-Scaling (ResizeObserver) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - var modalResizeObserver = null; - - function applyModalScaling() { - var modalContent = document.querySelector('.modal-content'); - var modalBody = document.getElementById('modalBody'); - if (!modalContent || !modalBody) return; - - modalContent.scrollTop = 0; - modalBody.scrollTop = 0; + recentSearchesList.appendChild(item); + }); + } - modalContent.style.overflow = 'auto'; + recentSearchesSection.style.display = 'block'; + if (resultsSection) resultsSection.style.display = 'none'; + if (tipsSection) tipsSection.style.display = 'block'; + } - modalBody.style.transform = ''; - modalBody.style.transformOrigin = ''; - modalBody.style.width = '100%'; - modalBody.style.height = ''; - modalBody.style.display = 'flex'; - modalBody.style.flexDirection = 'column'; - modalBody.style.alignItems = 'stretch'; - modalBody.style.gap = '1rem'; + function renderSuggestions(query) { + if (searchLoader) searchLoader.style.display = 'none'; + if (!query) { renderRecentSearches(); return; } - var targetEl = Array.from(modalBody.children).find(function (el) { - return el.tagName.toLowerCase() !== 'style'; - }) || modalBody.firstElementChild; - if (!targetEl) return; + var matches = getMatchingProjects(query); - targetEl.style.transform = ''; - targetEl.style.transformOrigin = ''; - targetEl.style.width = '100%'; - targetEl.style.maxWidth = '100%'; + if (matches.length === 0) { + if (resultsSection) resultsSection.style.display = 'none'; + if (recentSearchesSection) recentSearchesSection.style.display = 'none'; + if (tipsSection) tipsSection.style.display = 'block'; + if (noResultsMessage) noResultsMessage.style.display = 'block'; + return; } - function initModalScaling() { - applyModalScaling(); - - if (modalResizeObserver) { - modalResizeObserver.disconnect(); - } - - var modalBody = document.getElementById('modalBody'); - var targetEl = modalBody ? Array.from(modalBody.children).find(function (el) { - return el.tagName.toLowerCase() !== 'style'; - }) || modalBody.firstElementChild : null; - if (targetEl) { - modalResizeObserver = new ResizeObserver(function () { - requestAnimationFrame(applyModalScaling); - }); - modalResizeObserver.observe(targetEl); + if (noResultsMessage) noResultsMessage.style.display = 'none'; + + if (resultsList) { + resultsList.innerHTML = ''; + matches.slice(0, 8).forEach(function (project, index) { + var item = document.createElement('div'); + item.className = 'dropdown-item' + (index === selectedSuggestionIndex ? ' selected' : ''); + + var iconBox = document.createElement('div'); + iconBox.className = 'dropdown-item-icon'; + var banner = project.card.querySelector('.card-banner'); + if (banner) { + var img = document.createElement('img'); + img.src = banner.src; + img.alt = ''; + iconBox.appendChild(img); } - window.addEventListener('resize', applyModalScaling); - } + var titleBox = document.createElement('div'); + titleBox.className = 'dropdown-item-text'; + highlightText(titleBox, project.title, query); - function destroyModalScaling() { - if (modalResizeObserver) { - modalResizeObserver.disconnect(); - modalResizeObserver = null; - } - window.removeEventListener('resize', applyModalScaling); - } + var tag = document.createElement('span'); + tag.className = 'dropdown-item-tag'; + tag.textContent = project.category; - // โ”€โ”€ Focus Trap for Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function getFocusableElements(root) { - var selector = - 'button:not([disabled]), [href], input:not([disabled]), ' + - 'select:not([disabled]), textarea:not([disabled]), ' + - '[tabindex]:not([tabindex="-1"])'; - return Array.from(root.querySelectorAll(selector)).filter(function (el) { - return !el.closest('[aria-hidden="true"]') && - !el.classList.contains('visually-hidden'); + item.append(iconBox, titleBox, tag); + item.addEventListener('click', function () { selectSuggestion(project.title); }); + item.addEventListener('mouseenter', function () { + selectedSuggestionIndex = index; + updateSuggestionHighlight(); }); + resultsList.appendChild(item); + }); } - function trapFocus(modalEl) { - var handler = function (e) { - if (e.key !== 'Tab' || !modalEl.classList.contains('active')) return; - var focusables = getFocusableElements(modalEl); - if (!focusables.length) return; - var first = focusables[0]; - var last = focusables[focusables.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); last.focus({ preventScroll: true }); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); first.focus({ preventScroll: true }); - } - }; - document.addEventListener('keydown', handler, true); - return function () { document.removeEventListener('keydown', handler, true); }; + if (resultsSection) resultsSection.style.display = 'block'; + if (recentSearchesSection) recentSearchesSection.style.display = 'none'; + if (tipsSection) tipsSection.style.display = 'none'; + selectedSuggestionIndex = -1; + } + + function updateSuggestionHighlight() { + if (!resultsList) return; + var items = resultsList.querySelectorAll('.dropdown-item'); + items.forEach(function (item, i) { + item.classList.toggle('selected', i === selectedSuggestionIndex); + }); + } + + function selectSuggestion(title) { + if (!searchInput) return; + searchInput.value = title; + currentSearchQuery = title.toLowerCase(); + performSearch(); + closeDropdown(); + if (projectsSection) { + projectsSection.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' }); } - - // โ”€โ”€ Open / Close Project Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /* - * PLAYGROUND NOTE: openProjectSafe() is called synchronously from - * card clicks. It NEVER touches Pyodide and NEVER waits for it. - * The modal opens instantly regardless of Pyodide load state. - */ - function openProjectSafe(name, trigger) { - if (!modal || !modalBody) return; - - lastFocusedElement = trigger || document.activeElement; - - if (modalTitle) modalTitle.textContent = name || 'Interactive project'; - - modal.classList.add('active'); - modal.setAttribute('aria-hidden', 'false'); - - // Prevent scroll shift by adding padding equal to scrollbar width - var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - document.body.style.paddingRight = scrollbarWidth + 'px'; - document.body.style.overflow = 'hidden'; - setMainInert(true); - - safeRun(function () { - if (typeof getProjectHTML === 'function') { - modalBody.innerHTML = - getProjectHTML(name) || - '
Project content unavailable.
'; - } else { - modalBody.innerHTML = '
Project content unavailable.
'; - } - if (typeof initializeProject === 'function') initializeProject(name); - }); - - // Initialize reactive scale calculations - initModalScaling(); - - removeTrap = trapFocus(modal); - var focusables = getFocusableElements(modalBody); - var firstFocusable = focusables[0] || modalClose; - if (firstFocusable && typeof firstFocusable.focus === 'function') { - firstFocusable.focus({ preventScroll: true }); - } + } + + function performSearch() { + var query = currentSearchQuery; + if (!query) { + applyCategoryFilter(currentCategory); + if (emptyStateHint) emptyStateHint.textContent = 'Try adjusting your search or category filter.'; + return; } - function closeProjectSafe() { - if (!modal || !modal.classList.contains('active')) return; - - destroyModalScaling(); - - modal.classList.remove('active'); - modal.setAttribute('aria-hidden', 'true'); - document.body.style.paddingRight = ''; - document.body.style.overflow = ''; - setMainInert(false); - if (removeTrap) { removeTrap(); removeTrap = null; } - if (modalBody) { - modalBody.innerHTML = ''; - modalBody.style.transform = ''; - modalBody.style.transformOrigin = ''; - modalBody.style.width = ''; - modalBody.style.height = ''; - modalBody.style.display = ''; - modalBody.style.alignItems = ''; - modalBody.style.gap = ''; - } - if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') { - lastFocusedElement.focus({ preventScroll: true }); - } - lastFocusedElement = null; + if (currentCategory !== 'all') { + currentCategory = 'all'; + syncSidebarTabs('all'); + syncStickyTabs('all'); } - if (modalClose) modalClose.addEventListener('click', closeProjectSafe); - if (modal) { - modal.addEventListener('click', function (e) { - if (e.target === modal) closeProjectSafe(); - }); - } - document.addEventListener('keydown', function (e) { - if (e.key === 'Escape') closeProjectSafe(); - }); + recentSearches = recentSearches.filter(function (s) { return s !== query; }); + recentSearches.unshift(query); + recentSearches = recentSearches.slice(0, 10); + localStorage.setItem('recentSearches', JSON.stringify(recentSearches)); - // โ”€โ”€ Wire Cards and Play Buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + var visibleCount = 0; + var favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); projectCards.forEach(function (card) { - var name = card.getAttribute('data-project'); - var categoryName = card.getAttribute('data-category') || 'project'; - var metaRow = document.createElement('div'); - metaRow.className = 'project-card-meta'; - - var categoryBadge = document.createElement('span'); - categoryBadge.className = 'project-card-badge'; - categoryBadge.textContent = categoryName; - metaRow.appendChild(categoryBadge); - - var favBtn = document.createElement('button'); - favBtn.className = 'btn-favorite'; - favBtn.setAttribute('aria-label', 'Toggle favorite'); - favBtn.textContent = '\u2606'; - - var favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); - if (favorites.includes(name)) { - favBtn.classList.add('active'); - favBtn.textContent = '\u2605'; - } - - favBtn.addEventListener('click', function (e) { - e.stopPropagation(); - var favs = JSON.parse(localStorage.getItem('favorites') || '[]'); - var idx = favs.indexOf(name); - if (idx === -1) { - favs.push(name); - favBtn.classList.add('active'); - favBtn.textContent = '\u2605'; - } else { - favs.splice(idx, 1); - favBtn.classList.remove('active'); - favBtn.textContent = '\u2606'; - if (currentCategory === 'favorites') { - card.style.display = 'none'; - } - } - localStorage.setItem('favorites', JSON.stringify(favs)); - }); - var cardActions = card.querySelector('.card-actions'); - if (cardActions) { - cardActions.appendChild(favBtn); - } else { - card.appendChild(favBtn); - } - - var play = card.querySelector('.btn-play'); - if (play) { - play.setAttribute('aria-label', 'Open ' + name); - play.addEventListener('click', function (e) { - e.stopPropagation(); - openProjectSafe(name, play); - }); - } - card.appendChild(metaRow); - card.addEventListener('click', function () { openProjectSafe(name, card); }); + var category = card.getAttribute('data-category'); + var title = (card.querySelector('h3') || {}).textContent || ''; + var desc = (card.querySelector('p') || {}).textContent || ''; + var tags = (card.getAttribute('data-tags') || '').toLowerCase(); + var projectName = card.getAttribute('data-project'); + var isFav = favorites.includes(projectName); + + var catMatch = (currentCategory === 'all') || + (currentCategory === 'favorites' && isFav) || + (currentCategory !== 'favorites' && category === currentCategory); + var searchMatch = title.toLowerCase().includes(query) || + desc.toLowerCase().includes(query) || + tags.includes(query); + + if (catMatch && searchMatch) { + card.style.display = ''; + visibleCount++; + } else { + card.style.display = 'none'; + } }); - // โ”€โ”€ Random button is in the sidebar (#randomProjectBtnSidebar) โ”€โ”€โ”€โ”€โ”€ + if (emptyState) { + emptyState.style.display = visibleCount === 0 ? 'block' : 'none'; + if (visibleCount === 0 && emptyStateHint) { + emptyStateHint.textContent = 'No projects match "' + query + '". Try a different keyword.'; + } + } + if (projectCountBadge) projectCountBadge.textContent = String(visibleCount) + ' projects'; + } + + if (searchInput) { + var debouncedSearch = debounce(function (query) { + renderSuggestions(query); + }, 200); + + searchInput.addEventListener('input', function (e) { + var query = e.target.value.trim().toLowerCase(); + currentSearchQuery = query; + if (searchLoader) searchLoader.style.display = query ? 'block' : 'none'; + debouncedSearch(query); + performSearch(); + }); - featureLaunchers.forEach(function (node) { - node.addEventListener('click', function (e) { - if (e.target.closest('[data-project-target]') === node) { - var targetProject = node.getAttribute('data-project-target'); - if (targetProject) openProjectSafe(targetProject, node); - } - }); + searchInput.addEventListener('focus', function () { + if (searchDropdown) searchDropdown.classList.add('active'); + if (!currentSearchQuery) renderRecentSearches(); }); - if (!prefersReducedMotion()) { - projectCards.forEach(function (card) { - card.addEventListener('mousemove', function (event) { - var rect = card.getBoundingClientRect(); - var px = (event.clientX - rect.left) / rect.width; - var py = (event.clientY - rect.top) / rect.height; - var rotateY = (px - 0.5) * 10; - var rotateX = (0.5 - py) * 8; - card.style.transform = 'perspective(900px) rotateX(' + rotateX + 'deg) rotateY(' + rotateY + 'deg) translateY(-8px)'; - }); - card.addEventListener('mouseleave', function () { - card.style.transform = ''; - }); - }); + searchInput.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { closeDropdown(); searchInput.blur(); } + }); + } - var parallaxItems = document.querySelectorAll('[data-parallax]'); - window.addEventListener('scroll', function () { - var scrollY = window.scrollY; - parallaxItems.forEach(function (item) { - var ratio = parseFloat(item.getAttribute('data-parallax') || '0'); - item.style.transform = 'translateY(' + Math.round(scrollY * ratio) + 'px)'; - }); - }, { passive: true }); + document.addEventListener('click', function (e) { + if (searchDropdown && searchInput && + !searchDropdown.contains(e.target) && e.target !== searchInput) { + closeDropdown(); } + }); - // โ”€โ”€ Intersection Observer Animations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (!prefersReducedMotion()) { - try { - var observer = new IntersectionObserver(function (entries) { - entries.forEach(function (entry) { - if (entry.isIntersecting) { - entry.target.style.animation = 'fadeInUp 0.6s ease'; - } - }); - }, { threshold: 0.1, rootMargin: '0px 0px -100px 0px' }); - projectCards.forEach(function (c) { observer.observe(c); }); - } catch (e) { /* ignore */ } + document.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + if (searchInput) searchInput.focus(); } + }); - if (!prefersReducedMotion()) { - try { - var revealObserver = new IntersectionObserver(function (entries, obs) { - entries.forEach(function (entry) { - if (!entry.isIntersecting) return; - entry.target.classList.add('is-visible'); - obs.unobserve(entry.target); - }); - }, { threshold: 0.2 }); - - revealItems.forEach(function (item) { revealObserver.observe(item); }); - } catch (e) { /* ignore */ } - } else { - revealItems.forEach(function (item) { item.classList.add('is-visible'); }); - } + renderRecentSearches(); - // โ”€โ”€ Hero Timeline scroll reveal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - var timelineNodes = document.querySelectorAll('.timeline-node[data-reveal]'); - var heroTimeline = document.getElementById('heroTimeline'); + /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODAL + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + function getFocusableElements(root) { + var sel = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(root.querySelectorAll(sel)).filter(function (el) { + return !el.closest('[aria-hidden="true"]') && !el.classList.contains('visually-hidden'); + }); + } + + function trapFocus(modalEl) { + var handler = function (e) { + if (e.key !== 'Tab' || !modalEl.classList.contains('active')) return; + var focusables = getFocusableElements(modalEl); + if (!focusables.length) return; + var first = focusables[0]; + var last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); last.focus({ preventScroll: true }); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first.focus({ preventScroll: true }); + } + }; + document.addEventListener('keydown', handler, true); + return function () { document.removeEventListener('keydown', handler, true); }; + } + + function openProjectSafe(name, trigger) { + if (!modal || !modalBody) return; + lastFocusedElement = trigger || document.activeElement; + if (modalTitle) modalTitle.textContent = name || 'Interactive project'; + modal.classList.add('active'); + modal.setAttribute('aria-hidden', 'false'); + var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.body.style.paddingRight = scrollbarWidth + 'px'; + document.body.style.overflow = 'hidden'; + setMainInert(true); + + safeRun(function () { + if (typeof getProjectHTML === 'function') { + modalBody.innerHTML = getProjectHTML(name) || '
Project content unavailable.
'; + } else { + modalBody.innerHTML = '
Project content unavailable.
'; + } + if (typeof initializeProject === 'function') initializeProject(name); + }); - function updateSpineProgress() { - if (!heroTimeline) return; - var revealed = document.querySelectorAll('.timeline-node.revealed').length; - var total = timelineNodes.length; - var pct = total > 0 ? (revealed / total) * 100 : 0; - heroTimeline.style.setProperty('--spine-progress', pct); + removeTrap = trapFocus(modal); + var focusables = getFocusableElements(modalBody); + var firstFocusable = focusables[0] || modalClose; + if (firstFocusable && typeof firstFocusable.focus === 'function') { + firstFocusable.focus({ preventScroll: true }); + } + } + + function closeProjectSafe() { + if (!modal || !modal.classList.contains('active')) return; + modal.classList.remove('active'); + modal.setAttribute('aria-hidden', 'true'); + document.body.style.paddingRight = ''; + document.body.style.overflow = ''; + setMainInert(false); + if (removeTrap) { removeTrap(); removeTrap = null; } + if (modalBody) { + modalBody.innerHTML = ''; } + if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') { + lastFocusedElement.focus({ preventScroll: true }); + } + lastFocusedElement = null; + } - if (timelineNodes.length) { - timelineNodes[0].classList.add('revealed'); - updateSpineProgress(); + if (modalClose) modalClose.addEventListener('click', closeProjectSafe); + if (modal) { + modal.addEventListener('click', function (e) { + if (e.target === modal) closeProjectSafe(); + }); + } + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeProjectSafe(); + }); + + /* โ”€โ”€ Expose for inline use โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + window.openProjectSafe = openProjectSafe; + window.closeProjectSafe = closeProjectSafe; + + /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + WIRE PROJECT CARDS + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + projectCards.forEach(function (card) { + var name = card.getAttribute('data-project'); + + /* โ”€โ”€ Favorite Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var favBtn = document.createElement('button'); + favBtn.className = 'btn-favorite'; + favBtn.setAttribute('aria-label', 'Toggle favorite'); + favBtn.innerHTML = ''; + + var favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); + if (favorites.includes(name)) { + favBtn.classList.add('active'); + favBtn.innerHTML = ''; } - if (timelineNodes.length > 1 && !prefersReducedMotion()) { - var revealedCount = 1; - var scrollAccum = 0; - var prevScrollY = window.scrollY; - - window.addEventListener('scroll', function () { - var currentScrollY = window.scrollY; - var delta = currentScrollY - prevScrollY; - scrollAccum += delta; - prevScrollY = currentScrollY; - - if (scrollAccum >= 60) { - while (scrollAccum >= 60 && revealedCount < timelineNodes.length) { - timelineNodes[revealedCount].classList.add('revealed'); - revealedCount++; - scrollAccum -= 60; - } - } else if (scrollAccum <= -60) { - while (scrollAccum <= -60 && revealedCount > 1) { - revealedCount--; - timelineNodes[revealedCount].classList.remove('revealed'); - scrollAccum += 60; - } - } - updateSpineProgress(); - }, { passive: true }); - } else if (timelineNodes.length > 1) { - for (var i = 1; i < timelineNodes.length; i++) { - timelineNodes[i].classList.add('revealed'); + favBtn.addEventListener('click', function (e) { + e.stopPropagation(); + var favs = JSON.parse(localStorage.getItem('favorites') || '[]'); + var idx = favs.indexOf(name); + if (idx === -1) { + favs.push(name); + favBtn.classList.add('active'); + favBtn.innerHTML = ''; + } else { + favs.splice(idx, 1); + favBtn.classList.remove('active'); + favBtn.innerHTML = ''; + if (currentCategory === 'favorites') { + card.style.display = 'none'; } - updateSpineProgress(); - } + } + localStorage.setItem('favorites', JSON.stringify(favs)); + }); - // โ”€โ”€ Share Button Feature โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + var cardActions = card.querySelector('.card-actions'); + if (cardActions) cardActions.appendChild(favBtn); -// 1. Inject share button into every card dynamically -projectCards.forEach(function (card) { - var projectName = card.getAttribute('data-project'); + /* โ”€โ”€ Share Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ var shareBtn = document.createElement('button'); shareBtn.className = 'btn-share'; - shareBtn.setAttribute('aria-label', 'Share ' + projectName); + shareBtn.setAttribute('aria-label', 'Share ' + name); shareBtn.innerHTML = ''; shareBtn.title = 'Copy shareable link'; shareBtn.addEventListener('click', function (e) { - e.stopPropagation(); // prevent card click opening modal - var url = window.location.origin + window.location.pathname + '?project=' + encodeURIComponent(projectName); - navigator.clipboard.writeText(url).then(function () { - showToast('Link copied!'); - }).catch(function () { - // Fallback for browsers that block clipboard - showToast('Copy this: ' + url); - }); + e.stopPropagation(); + var url = window.location.origin + window.location.pathname + '?project=' + encodeURIComponent(name); + navigator.clipboard.writeText(url).then(function () { + showToast('Link copied!'); + }).catch(function () { + showToast('Copy this: ' + url); + }); }); - var cardActions = card.querySelector('.card-actions'); - if (cardActions) { - cardActions.appendChild(shareBtn); - } else { - card.appendChild(shareBtn); + if (cardActions) cardActions.appendChild(shareBtn); + + /* โ”€โ”€ Play Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var playBtns = card.querySelectorAll('.btn-play'); + playBtns.forEach(function (play) { + play.setAttribute('aria-label', 'Open ' + name); + play.addEventListener('click', function (e) { + e.stopPropagation(); + openProjectSafe(name, play); + }); + }); + + /* โ”€โ”€ Card Click โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + card.addEventListener('click', function (e) { + if (e.target.closest('.btn-play') || e.target.closest('.btn-favorite') || e.target.closest('.btn-share')) return; + openProjectSafe(name, card); + }); + + /* โ”€โ”€ Card Mouse Tracking for Border Glow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + if (!prefersReducedMotion()) { + card.addEventListener('mousemove', function (e) { + var rect = card.getBoundingClientRect(); + var x = ((e.clientX - rect.left) / rect.width) * 100; + var y = ((e.clientY - rect.top) / rect.height) * 100; + card.style.setProperty('--mouse-x', x + '%'); + card.style.setProperty('--mouse-y', y + '%'); + }); } -}); + }); -// 2. Toast notification helper -function showToast(message) { + /* โ”€โ”€ Toast โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function showToast(message) { var existing = document.getElementById('shareToast'); if (existing) existing.remove(); - var toast = document.createElement('div'); toast.id = 'shareToast'; toast.className = 'share-toast'; toast.textContent = message; document.body.appendChild(toast); - - // Trigger animation requestAnimationFrame(function () { - toast.classList.add('share-toast--visible'); + toast.classList.add('share-toast--visible'); }); - setTimeout(function () { - toast.classList.remove('share-toast--visible'); - setTimeout(function () { toast.remove(); }, 300); + toast.classList.remove('share-toast--visible'); + setTimeout(function () { toast.remove(); }, 300); }, 2500); -} + } -// 3. On page load, check for ?project= param and auto-open it -(function () { + /* โ”€โ”€ URL params auto-open โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + (function () { var params = new URLSearchParams(window.location.search); var projectParam = params.get('project'); - if (!projectParam) return; + if (projectParam) { + var match = projectCards.find(function (c) { return c.getAttribute('data-project') === projectParam; }); + if (match) { + setTimeout(function () { + openProjectSafe(projectParam, match); + match.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + } + var catParam = params.get('category'); + var valid = ['all', 'games', 'math', 'utilities', 'playground', 'favorites']; + if (catParam && valid.includes(catParam)) { + var tab = document.querySelector('[data-category="' + catParam + '"]'); + if (tab) setTimeout(function () { tab.click(); }, 100); + } + })(); + + /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + TIMELINE SCROLL REVEAL + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + var timelineItems = document.querySelectorAll('.timeline-item[data-reveal]'); + var timelineFill = document.getElementById('timelineFill'); + var timelineSection = document.getElementById('timelineSection'); + + if (timelineItems.length && !prefersReducedMotion()) { + var timelineObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + } + }); + }, { threshold: 0.25, rootMargin: '0px 0px -50px 0px' }); - var matchingCard = projectCards.find(function (card) { - return card.getAttribute('data-project') === projectParam; + timelineItems.forEach(function (item) { + timelineObserver.observe(item); }); - if (matchingCard) { - setTimeout(function () { - var projectName = matchingCard.getAttribute('data-project'); - openProjectSafe(projectName, matchingCard); - matchingCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 300); // small delay so the page fully loads first + /* โ”€โ”€ Serpentine SVG Winding Timeline Path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var svgNamespace = 'http://www.w3.org/2000/svg'; + + function getElementOffset(el, parent) { + var top = 0; + var left = 0; + var curr = el; + while (curr && curr !== parent) { + top += curr.offsetTop || 0; + left += curr.offsetLeft || 0; + curr = curr.offsetParent; + } + return { top: top, left: left }; } -})(); -// 4. On page load, check for ?category= param and apply filter -(function () { - var params = new URLSearchParams(window.location.search); - var categoryParam = params.get('category'); - var validCategories = ['all', 'games', 'math', 'utilities', 'playground', 'favorites']; - if (!categoryParam || !validCategories.includes(categoryParam)) return; + function rebuildTimelineSvg() { + var container = document.querySelector('.timeline-container'); + if (!container) return; + var dots = document.querySelectorAll('.timeline-dot'); + if (dots.length < 2) return; - var matchingTab = document.querySelector('[data-category="' + categoryParam + '"]'); - if (matchingTab) { - setTimeout(function () { - matchingTab.click(); - }, 100); + var containerWidth = container.offsetWidth; + var containerHeight = container.offsetHeight; + + var svg = document.getElementById('timelineSvg'); + if (!svg) { + svg = document.createElementNS(svgNamespace, 'svg'); + svg.id = 'timelineSvg'; + svg.setAttribute('class', 'timeline-svg'); + + var defs = document.createElementNS(svgNamespace, 'defs'); + var grad = document.createElementNS(svgNamespace, 'linearGradient'); + grad.id = 'timelineGrad'; + grad.setAttribute('x1', '0%'); + grad.setAttribute('y1', '0%'); + grad.setAttribute('x2', '0%'); + grad.setAttribute('y2', '100%'); + + var stop1 = document.createElementNS(svgNamespace, 'stop'); + stop1.setAttribute('offset', '0%'); + stop1.setAttribute('stop-color', 'var(--accent)'); + + var stop2 = document.createElementNS(svgNamespace, 'stop'); + stop2.setAttribute('offset', '100%'); + stop2.setAttribute('stop-color', '#06b6d4'); + + grad.appendChild(stop1); + grad.appendChild(stop2); + defs.appendChild(grad); + + // Define a dynamic layout mask path for progress crawling + var mask = document.createElementNS(svgNamespace, 'mask'); + mask.id = 'timelineMask'; + + var maskPath = document.createElementNS(svgNamespace, 'path'); + maskPath.id = 'timelineMaskPath'; + maskPath.setAttribute('fill', 'none'); + maskPath.setAttribute('stroke', '#ffffff'); + maskPath.setAttribute('stroke-width', '24'); // Wide enough to fully cover glowing dots + maskPath.setAttribute('stroke-linecap', 'round'); + + mask.appendChild(maskPath); + defs.appendChild(mask); + svg.appendChild(defs); + + var track = document.createElementNS(svgNamespace, 'path'); + track.id = 'timelineSvgTrack'; + track.setAttribute('class', 'timeline-svg-track'); + track.setAttribute('fill', 'none'); + + var fill = document.createElementNS(svgNamespace, 'path'); + fill.id = 'timelineSvgFill'; + fill.setAttribute('class', 'timeline-svg-fill'); + fill.setAttribute('fill', 'none'); + fill.setAttribute('stroke', 'var(--accent)'); + fill.setAttribute('mask', 'url(#timelineMask)'); + + svg.appendChild(track); + svg.appendChild(fill); + + var grid = document.querySelector('.timeline-grid'); + container.insertBefore(svg, grid); + } + + // Determine layout stable coordinate points for all timeline dots + var coords = []; + dots.forEach(function (dot) { + var offset = getElementOffset(dot, container); + var x = offset.left + dot.offsetWidth / 2; + var y = offset.top + dot.offsetHeight / 2; + coords.push({ x: x, y: y }); + }); + + // Create the winding path + var d = ""; + var startX = containerWidth / 2; + d += "M " + startX + " 0"; + d += " L " + coords[0].x + " " + coords[0].y; + + // Calculate a sweep width that is perfectly responsive + // e.g. 16% of container width, capped at 180px for desktop beauty + var W = Math.min(180, containerWidth * 0.16); + + for (var i = 0; i < coords.length - 1; i++) { + var pStart = coords[i]; + var pEnd = coords[i + 1]; + var H = pEnd.y - pStart.y; + var dy = H * 0.35; // Symmetrical control point height + + // Even segments (0, 2, 4...) snake to the right, odd segments to the left + var dx = (i % 2 === 0) ? W : -W; + + var cp1x = pStart.x + dx; + var cp1y = pStart.y + dy; + var cp2x = pEnd.x + dx; + var cp2y = pEnd.y - dy; + + d += " C " + cp1x + " " + cp1y + ", " + cp2x + " " + cp2y + ", " + pEnd.x + " " + pEnd.y; + } + + // Straight exit to the bottom + d += " L " + coords[coords.length - 1].x + " " + containerHeight; + + var trackPath = document.getElementById('timelineSvgTrack'); + var fillPath = document.getElementById('timelineSvgFill'); + var maskPath = document.getElementById('timelineMaskPath'); + if (trackPath && fillPath && maskPath) { + trackPath.setAttribute('d', d); + fillPath.setAttribute('d', d); + maskPath.setAttribute('d', d); + + var totalLength = maskPath.getTotalLength(); + maskPath.style.strokeDasharray = totalLength; + maskPath.dataset.totalLength = totalLength; + + // Trigger scroll progress sync immediately + updateTimelineFill(); + } } -})(); + + /* โ”€โ”€ Timeline Fill Progress โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function updateTimelineFill() { + if (!timelineSection) return; + var container = document.querySelector('.timeline-container'); + if (!container) return; + + var containerRect = container.getBoundingClientRect(); + var viewportCenterY = window.innerHeight / 2; + + // Calculate relative vertical scroll position of the viewport center relative to the container + var relativeY = viewportCenterY - containerRect.top; + var offset = Math.max(0, Math.min(1, relativeY / containerRect.height)); + + /* โ”€โ”€ Dynamic SVG path mask scroll synchronization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var maskPath = document.getElementById('timelineMaskPath'); + if (maskPath && maskPath.dataset.totalLength) { + var totalLength = parseFloat(maskPath.dataset.totalLength); + var dashoffset = totalLength - (offset * totalLength); + maskPath.style.strokeDashoffset = Math.max(0, Math.min(totalLength, dashoffset)); + } + + /* โ”€โ”€ Activate item based on viewport center crossing timeline dots โ”€โ”€ */ + var activeIdx = -1; + var dots = document.querySelectorAll('.timeline-dot'); + + dots.forEach(function (dot, i) { + var dotRect = dot.getBoundingClientRect(); + var dotCenterY = dotRect.top + dotRect.height / 2; + + // A dot is crossed/passed if its vertical center in the viewport is <= the viewport center + if (dotCenterY <= viewportCenterY) { + activeIdx = i; + } + }); + + timelineItems.forEach(function (item, i) { + item.classList.toggle('active', i === activeIdx); + }); + } + + // Initialize SVG path layout recalculations on page render & resize + rebuildTimelineSvg(); + window.addEventListener('resize', debounce(rebuildTimelineSvg, 150)); + window.addEventListener('scroll', updateTimelineFill, { passive: true }); + } else if (timelineItems.length) { + timelineItems.forEach(function (item) { item.classList.add('visible'); }); + } + + /* โ”€โ”€ Reveal on Scroll (general) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + var revealItems = document.querySelectorAll('.reveal-on-scroll'); + if (revealItems.length && !prefersReducedMotion()) { + var revealObserver = new IntersectionObserver(function (entries, obs) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add('is-visible'); + obs.unobserve(entry.target); + }); + }, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }); + revealItems.forEach(function (item) { revealObserver.observe(item); }); + } else { + revealItems.forEach(function (item) { item.classList.add('is-visible'); }); + } + + /* โ”€โ”€ Footer category links โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + document.querySelectorAll('.footer-cat-link').forEach(function (a) { + a.addEventListener('click', function (e) { + e.preventDefault(); + var cat = a.getAttribute('data-cat'); + var tab = document.querySelector('.sidebar-tab[data-category="' + cat + '"]'); + if (tab) tab.click(); + }); + }); }); diff --git a/web-app/js/projects.js b/web-app/js/projects.js index 04ba8fd..3084f6b 100644 --- a/web-app/js/projects.js +++ b/web-app/js/projects.js @@ -1,4 +1,4 @@ -// Project Registry +๏ปฟ// Project Registry // Each project's HTML and logic lives in its own file under js/projects/ @@ -39,6 +39,7 @@ function getProjectHTML(projectName) { '2048-game': () => get2048GameHTML(), 'productive-pet': () => getProductivePetHTML(), 'color-palette': () => getColorPaletteHTML(), + 'caesar-cipher': () => getCaesarCipherHTML(), }; try { @@ -3022,187 +3023,541 @@ function initTowerOfHanoi() { initializeGame(); } -function getTicTacToeHTML() { - return ` - - -
-

Tic Tac Toe

- -
- - - - - - - - - - - -
- -

Player X's Turn

+// ============================================ +// Tic-Tac-Toe +// ============================================ - -
- `; +function getTicTacToeHTML() { + return ` +
+ + +
+ +

Tic Tac Toe

+

Two players or vs AI โ€” classic strategy game!

+ +
+ Game Mode +
+ + +
+
+ + + +
+ Rounds +
+ + + +
+
+ +
+
+ Player 1 โŒ + +
+
+ Player 2 โญ• + +
+
+ + +
+ + +
+ + +
+
+
P1
+
0
+
+
+
Draws
+
0
+
+
+
P2
+
0
+
+
+ +
Round 1 of 3
+ + +
+ โŒ + Player 1's turn +
+ + +
+
+ + ${[0,1,2,3,4,5,6,7,8].map(i => + `` + ).join('')} +
+ + + + + +
+ + + + +
+ + +
+
๐Ÿ†
+
Player 1 Wins!
+
+ P1 + 0 + โ€” + 0 + P2 +
+
0 draws
+
+ + +
+
+ +
+`; } - -let currentPlayer = 'X'; - -let board = [ - '', '', '', - '', '', '', - '', '', '' -]; - -let gameActive = true; - -const winningPatterns = [ - [0,1,2], - [3,4,5], - [6,7,8], - - [0,3,6], - [1,4,7], - [2,5,8], - - [0,4,8], - [2,4,6] -]; - -function makeMove(index) { - - if (!gameActive || board[index] !== '') { - return; +function initTicTacToe() { + + // Win combos + const WINS = [ + [0,1,2],[3,4,5],[6,7,8], // rows + [0,3,6],[1,4,7],[2,5,8], // cols + [0,4,8],[2,4,6] // diagonals + ]; + + // Win-line centre coordinates (column, row) in 0-2 grid space + const WIN_COORDS = [ + [[0,0],[2,0]], [[0,1],[2,1]], [[0,2],[2,2]], // rows + [[0,0],[0,2]], [[1,0],[1,2]], [[2,0],[2,2]], // cols + [[0,0],[2,2]], [[2,0],[0,2]] // diagonals + ]; + + // โ”€โ”€ State โ”€โ”€ + let mode = "2p"; + let difficulty = "easy"; + let maxRounds = 3; + let p1 = "Player 1"; + let p2 = "Player 2"; + let scores = { p1:0, p2:0, draws:0 }; + let board = []; + let current = "X"; // "X" | "O" + let round = 1; + let gameOver = false; + + // โ”€โ”€ Helpers โ”€โ”€ + function qs(sel, ctx) { return (ctx||document).querySelector(sel); } + + // Show one of the three screens + function showScreen(id) { + ["ttt-setup","ttt-game","ttt-final"].forEach(s => { + const el = document.getElementById(s); + if (el) { + el.classList.toggle("ttt-screen--active", s === id); + } + }); + } + + // Pill-toggle group helper + function initPillGroup(groupId, onChange) { + const grp = document.getElementById(groupId); + if (!grp) return; + grp.querySelectorAll(".ttt-pill").forEach(btn => { + btn.addEventListener("click", () => { + grp.querySelectorAll(".ttt-pill").forEach(b => b.classList.remove("ttt-pill--on")); + btn.classList.add("ttt-pill--on"); + onChange(btn.dataset.val); + }); + }); + } + + // โ”€โ”€ Wire up Setup โ”€โ”€ + initPillGroup("ttt-mode-group", val => { + mode = val; + const diffGroup = document.getElementById("ttt-diff-group"); + const p2box = document.getElementById("ttt-p2-box"); + const p2inp = document.getElementById("ttt-p2"); + if (val === "ai") { + diffGroup.style.display = "block"; + p2box.classList.add("ttt-dimmed"); + p2inp.disabled = true; + p2inp.placeholder = "Computer ๐Ÿค–"; + } else { + diffGroup.style.display = "none"; + p2box.classList.remove("ttt-dimmed"); + p2inp.disabled = false; + p2inp.placeholder = "Player 2"; } + }); + + initPillGroup("ttt-diff-pills", val => { difficulty = val; }); + initPillGroup("ttt-rounds-group", val => { maxRounds = parseInt(val); }); + + // Start button + const startBtn = document.getElementById("ttt-start"); + if (startBtn) { + startBtn.addEventListener("click", () => { + p1 = (document.getElementById("ttt-p1").value.trim()) || "Player 1"; + p2 = mode === "ai" + ? "Computer ๐Ÿค–" + : ((document.getElementById("ttt-p2").value.trim()) || "Player 2"); + scores = { p1:0, p2:0, draws:0 }; + round = 1; + newRound(); + showScreen("ttt-game"); + }); + } + + // Back / Menu buttons + const backBtn = document.getElementById("ttt-back"); + if (backBtn) backBtn.addEventListener("click", () => showScreen("ttt-setup")); + + const menuBtn = document.getElementById("ttt-menu"); + if (menuBtn) menuBtn.addEventListener("click", () => showScreen("ttt-setup")); + + // Rematch button + const rematchBtn = document.getElementById("ttt-rematch"); + if (rematchBtn) { + rematchBtn.addEventListener("click", () => { + scores = { p1:0, p2:0, draws:0 }; + round = 1; + newRound(); + showScreen("ttt-game"); + }); + } + + // Next-round button + const nextBtn = document.getElementById("ttt-next"); + if (nextBtn) { + nextBtn.addEventListener("click", () => { + const majority = Math.ceil(maxRounds / 2); + const matchDone = round >= maxRounds + || scores.p1 >= majority + || scores.p2 >= majority; + if (matchDone) { + renderFinal(); + showScreen("ttt-final"); + } else { + round++; + newRound(); + } + }); + } + + // โ”€โ”€ Round management โ”€โ”€ + function newRound() { + board = Array(9).fill(null); + current = "X"; + gameOver = false; + + // Reset cells + document.querySelectorAll(".ttt-cell").forEach(c => { + c.textContent = ""; + c.className = "ttt-cell"; + c.disabled = false; + }); - const cells = document.querySelectorAll('.cell'); + // Hide result overlay + const overlay = document.getElementById("ttt-result-overlay"); + if (overlay) overlay.style.display = "none"; - board[index] = currentPlayer; + // Clear win line + clearWinLine(); - cells[index].innerText = currentPlayer; + // Update scoreboard + syncScoreboard(); - if (currentPlayer === 'X') { - cells[index].style.color = '#ff4d4d'; - } else { - cells[index].style.color = '#4d79ff'; + // Round label + const tag = document.getElementById("ttt-round-tag"); + if (tag) { + tag.textContent = maxRounds === 1 + ? "Single Round" + : `Round ${round} of ${maxRounds}`; } - // CHECK WINNER FIRST - if (checkWinner()) { - - document.getElementById('status').innerText = - `Player ${currentPlayer} Wins!`; + refreshTurnBanner(); - gameActive = false; - return; + // If AI goes first (not default, but safe to handle) + if (mode === "ai" && current === "O") { + lockBoard(true); + setTimeout(aiTurn, 600); } - - // DRAW CONDITION - const isDraw = board.every(cell => cell !== ''); - - if (isDraw) { - - document.getElementById('status').innerText = - "It's a Draw!"; - - gameActive = false; - return; + } + + function syncScoreboard() { + const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; + set("ttt-sn1", p1); + set("ttt-sn2", p2); + set("ttt-sv1", scores.p1); + set("ttt-sv2", scores.p2); + set("ttt-svd", scores.draws); + } + + function refreshTurnBanner() { + const name = current === "X" ? p1 : p2; + const sym = current === "X" ? "X" : "O"; + const symEl = document.getElementById("ttt-turn-sym"); + const nameEl = document.getElementById("ttt-turn-name"); + const banner = document.getElementById("ttt-turn-banner"); + if (symEl) symEl.textContent = sym; + if (nameEl) nameEl.textContent = name; + if (banner) { + banner.classList.toggle("ttt-turn-banner--x", current === "X"); + banner.classList.toggle("ttt-turn-banner--o", current === "O"); } + } - // SWITCH PLAYER - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; - - document.getElementById('status').innerText = - `Player ${currentPlayer}'s Turn`; -} - - -function checkWinner() { - - return winningPatterns.some(pattern => { - - return pattern.every(index => { - return board[index] === currentPlayer; - }); + // โ”€โ”€ Cell clicks โ”€โ”€ + document.querySelectorAll(".ttt-cell").forEach(cell => { + cell.addEventListener("click", () => { + const i = parseInt(cell.dataset.i); + if (gameOver || board[i]) return; + if (mode === "ai" && current === "O") return; // AI's turn + placeMove(i, current); + afterMove(); }); -} + }); + + function placeMove(i, sym) { + board[i] = sym; + const cell = document.querySelector(`.ttt-cell[data-i="${i}"]`); + if (!cell) return; + cell.textContent = sym; + cell.classList.add(sym === "X" ? "ttt-cell--x" : "ttt-cell--o"); + cell.disabled = true; + } + + function afterMove() { + const win = getWinner(board); + if (win) { endRound(win); return; } + if (board.every(c=>c)){ endRound(null); return; } + + current = current === "X" ? "O" : "X"; + refreshTurnBanner(); + + if (mode === "ai" && current === "O") { + lockBoard(true); + setTimeout(aiTurn, 480 + Math.random()*200); + } + } -function resetGame() { + function lockBoard(on) { + document.querySelectorAll(".ttt-cell").forEach(c => { + if (!board[parseInt(c.dataset.i)]) c.disabled = on; + }); + } + + // โ”€โ”€ Win detection โ”€โ”€ + function getWinner(b) { + for (let idx=0; idx { + const cell = document.querySelector(`.ttt-cell[data-i="${i}"]`); + if (cell) cell.classList.add("ttt-cell--win"); + }); + // Draw SVG win line + drawWinLine(win.coordIdx); + + const winnerName = win.sym === "X" ? p1 : p2; + if (win.sym === "X") scores.p1++; else scores.p2++; + syncScoreboard(); + + setResult("๐Ÿ†", `${winnerName} wins this round!`); + } else { + scores.draws++; + syncScoreboard(); + setResult("๐Ÿค", "It's a draw!"); + } - board = [ - '', '', '', - '', '', '', - '', '', '' - ]; + // Update Next button label + setTimeout(() => { + const majority = Math.ceil(maxRounds / 2); + const matchDone = round >= maxRounds + || scores.p1 >= majority + || scores.p2 >= majority; + const nextBtn = document.getElementById("ttt-next"); + if (nextBtn) nextBtn.textContent = matchDone ? "See Results โ†’" : "Next Round โ†’"; + + const overlay = document.getElementById("ttt-result-overlay"); + if (overlay) overlay.style.display = "flex"; + }, 600); + } + + function setResult(emoji, text) { + const e = document.getElementById("ttt-res-emoji"); + const t = document.getElementById("ttt-res-text"); + if (e) e.textContent = emoji; + if (t) t.textContent = text; + } + + // โ”€โ”€ Win-line SVG โ”€โ”€ + // Grid cells are (col, row) 0-indexed; centre of cell = col+0.5, row+0.5 + function drawWinLine(comboIdx) { + const line = document.getElementById("ttt-win-line"); + const svg = document.getElementById("ttt-win-svg"); + if (!line || !svg) return; + + const [[c1,r1],[c2,r2]] = WIN_COORDS[comboIdx]; + line.setAttribute("x1", c1 + 0.5); + line.setAttribute("y1", r1 + 0.5); + line.setAttribute("x2", c2 + 0.5); + line.setAttribute("y2", r2 + 0.5); + line.setAttribute("opacity", "1"); + svg.classList.add("ttt-win-svg--visible"); +} - currentPlayer = 'X'; + function clearWinLine() { + const line = document.getElementById("ttt-win-line"); + const svg = document.getElementById("ttt-win-svg"); + if (line) line.setAttribute("opacity","0"); + if (svg) svg.classList.remove("ttt-win-svg--visible"); + } + + // โ”€โ”€ Final screen โ”€โ”€ + function renderFinal() { + const set = (id,v) => { const el=document.getElementById(id); if(el) el.textContent=v; }; + set("ttt-fp1", p1); + set("ttt-fp2", p2); + set("ttt-fp1s", scores.p1); + set("ttt-fp2s", scores.p2); + set("ttt-final-draws", `${scores.draws} draw${scores.draws!==1?"s":""}`); + + let title; + if (scores.p1 > scores.p2) title = `๐Ÿ† ${p1} wins the match!`; + else if (scores.p2 > scores.p1) title = `๐Ÿ† ${p2} wins the match!`; + else title = "๐Ÿค The match is tied!"; + set("ttt-final-title", title); + } + + // โ”€โ”€ AI engines โ”€โ”€ + function freeCells(b) { + return b.reduce((acc,v,i) => { if(!v) acc.push(i); return acc; }, []); + } + + function checkWinFor(b, sym) { + return WINS.some(([a,x,c]) => b[a]===sym && b[x]===sym && b[c]===sym); + } + + function minimax(b, isMax, alpha, beta, depth) { + if (checkWinFor(b,"O")) return 10 - depth; + if (checkWinFor(b,"X")) return depth - 10; + if (b.every(c=>c)) return 0; + + const moves = freeCells(b); + if (isMax) { + let best = -Infinity; + for (const m of moves) { + b[m] = "O"; + best = Math.max(best, minimax(b, false, alpha, beta, depth+1)); + b[m] = null; + alpha = Math.max(alpha, best); + if (beta <= alpha) break; + } + return best; + } else { + let best = Infinity; + for (const m of moves) { + b[m] = "X"; + best = Math.min(best, minimax(b, true, alpha, beta, depth+1)); + b[m] = null; + beta = Math.min(beta, best); + if (beta <= alpha) break; + } + return best; + } + } - gameActive = true; + function chooseMove(b, diff) { + const moves = freeCells(b); + if (!moves.length) return null; - const cells = document.querySelectorAll('.cell'); + // Easy โ€” random + if (diff === "easy") return moves[Math.floor(Math.random()*moves.length)]; - cells.forEach(cell => { - cell.textContent = ''; - }); + // Medium โ€” win โ†’ block โ†’ center/corners + if (diff === "medium") { + for (const m of moves) { b[m]="O"; if(checkWinFor(b,"O")){b[m]=null;return m;} b[m]=null; } + for (const m of moves) { b[m]="X"; if(checkWinFor(b,"X")){b[m]=null;return m;} b[m]=null; } + for (const p of [4,0,2,6,8,1,3,5,7]) { if(!b[p]) return p; } + return moves[0]; + } - document.getElementById('status').textContent = - "Player X's Turn"; -} + // Hard โ€” minimax + let bestScore=-Infinity, bestMove=moves[0]; + for (const m of moves) { + b[m]="O"; + const s = minimax(b, false, -Infinity, Infinity, 0); + b[m]=null; + if (s > bestScore) { bestScore=s; bestMove=m; } + } + return bestMove; + } + + function aiTurn() { + if (gameOver) return; + const move = chooseMove([...board], difficulty); // pass copy so minimax doesn't corrupt state + lockBoard(false); + if (move !== null) placeMove(move, "O"); + afterMove(); + } + +} // end initTicTacToe +// ================================ function getProductivePetHTML() { return `
@@ -3276,7 +3631,8 @@ function initializeProject(projectName) { 'simon-says': 'initSimonSays', '2048-game': 'init2048Game', 'color-palette': 'initColorPalette', - 'math-quiz': 'initMathQuiz' + 'math-quiz': 'initMathQuiz', + 'caesar-cipher': 'initCaesarCipher' }; const initializerName = initializers[projectName]; diff --git a/web-app/js/projects/ceasar-ciphar.js b/web-app/js/projects/ceasar-ciphar.js new file mode 100644 index 0000000..21e449a --- /dev/null +++ b/web-app/js/projects/ceasar-ciphar.js @@ -0,0 +1,412 @@ +function getCaesarCipherHTML() { + return ` +
+

๐Ÿ” Caesar Cipher Encoder & Decoder

+

Encrypt and decrypt messages using Caesar Cipher

+ +
+
+ + + + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+

Output:

+
+

Your result will appear here...

+
+ +
+ +
+

๐Ÿ”ก How the shift works:

+
+
+
+
+ + + `; +} + + +function initCaesarCipher() { + const textBox = document.getElementById('cipherInput'); + const goBtn = document.getElementById('cipherBtn'); + const clearBtn = document.getElementById('clearBtn'); + const copyBtn = document.getElementById('copyBtn'); + const outputBox = document.getElementById('cipherOutput'); + const shiftNumber = document.getElementById('shiftInput'); + const shiftSlide = document.getElementById('shiftSlider'); + const encryptRadio = document.getElementById('modeEncrypt'); + const decryptRadio = document.getElementById('modeDecrypt'); + const theLabel = document.getElementById('inputLabel'); + const previewDiv = document.getElementById('shiftPreview'); + + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + function encryptText(text, shift) { + let result = ''; + + for (const ch of text) { + if (ch >= 'A' && ch <= 'Z') { + result += String.fromCharCode(((ch.charCodeAt(0) - 65 + shift) % 26 + 26) % 26 + 65); + } else if (ch >= 'a' && ch <= 'z') { + result += String.fromCharCode(((ch.charCodeAt(0) - 97 + shift) % 26 + 26) % 26 + 97); + } else { + result += ch; + } + } + + return result; + } + + function decryptText(text, shift) { + return encryptText(text, -shift); + } + + function buildAlphabetRow(label, getChar, className) { + const row = document.createElement('div'); + row.className = 'alphabet-row'; + + const labelEl = document.createElement('span'); + labelEl.className = 'row-label'; + labelEl.textContent = label; + row.appendChild(labelEl); + + for (let i = 0; i < 26; i++) { + const box = document.createElement('span'); + box.className = `letter-box ${className}`; + box.textContent = getChar(i); + row.appendChild(box); + } + + return row; + } + + function showPreview(shift) { + previewDiv.innerHTML = ''; + previewDiv.appendChild(buildAlphabetRow('Original:', i => ALPHABET[i], 'plain')); + previewDiv.appendChild(buildAlphabetRow(`Shift +${shift}:`, i => ALPHABET[(i + shift) % 26], 'cipher')); + } + + function setMode(isEncrypt) { + theLabel.textContent = isEncrypt ? '๐Ÿ“ Enter Text to Encrypt:' : '๐Ÿ“จ Enter Text to Decrypt:'; + textBox.placeholder = isEncrypt ? 'Type your message here...' : 'Paste the encrypted text here...'; + textBox.value = ''; + outputBox.innerHTML = '

Your result will appear here...

'; + copyBtn.style.display = 'none'; + goBtn.textContent = isEncrypt ? '๐Ÿ” Encrypt Message' : '๐Ÿ“ Decrypt Message'; + } + + function syncSlider(val) { + const clamped = Math.min(25, Math.max(1, val)); + shiftNumber.value = clamped; + shiftSlide.value = clamped; + showPreview(clamped); + } + + function showError(msg) { + outputBox.innerHTML = `

โŒ ${msg}

`; + copyBtn.style.display = 'none'; + } + + function showResult(text) { + outputBox.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'result-text'; + p.textContent = text; + outputBox.appendChild(p); + copyBtn.style.display = 'inline-block'; + copyBtn.dataset.result = text; + } + + encryptRadio.addEventListener('change', () => setMode(true)); + decryptRadio.addEventListener('change', () => setMode(false)); + + shiftSlide.addEventListener('input', () => syncSlider(parseInt(shiftSlide.value))); + shiftNumber.addEventListener('input', () => syncSlider(parseInt(shiftNumber.value))); + + goBtn.addEventListener('click', () => { + const text = textBox.value; + const shift = parseInt(shiftNumber.value); + + if (!text.trim()) return showError('Please enter some text first!'); + if (isNaN(shift) || shift < 1 || shift > 25) return showError('Please enter a shift value between 1 and 25!'); + + const result = encryptRadio.checked ? encryptText(text, shift) : decryptText(text, shift); + showResult(result); + }); + + clearBtn.addEventListener('click', () => { + textBox.value = ''; + outputBox.innerHTML = '

Your result will appear here...

'; + copyBtn.style.display = 'none'; + }); + + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(copyBtn.dataset.result).then(() => { + const original = copyBtn.textContent; + copyBtn.textContent = 'โœ… Copied!'; + setTimeout(() => copyBtn.textContent = original, 1500); + }); + }); + + showPreview(3); +} \ No newline at end of file diff --git a/web-app/js/projects/tic-tac-toe.js b/web-app/js/projects/tic-tac-toe.js index c86f237..1f26994 100644 --- a/web-app/js/projects/tic-tac-toe.js +++ b/web-app/js/projects/tic-tac-toe.js @@ -1,173 +1,538 @@ +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// ๐ŸŽฎ Tic Tac Toe โ€” web-app/js/projects/tic-tac-toe.js +// Category : games +// Features : 2-Player | vs AI (Easy/Medium/Hard) | +// Score tracking | Best-of-1/3/5 rounds +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +// โ”€โ”€ 1. HTML Template โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function getTicTacToeHTML() { - return ` -
-

๐Ÿงฉ Tic-Tac-Toe

-
- - - -
-
-
- - - - - - - - - -
- -

Player X's turn

- - -
-
- `; + return ` +
+ + +
+ +

Tic Tac Toe

+

Two players or vs AI โ€” classic strategy game!

+ +
+ Game Mode +
+ + +
+
+ + + +
+ Rounds +
+ + + +
+
+ +
+
+ Player 1 โŒ + +
+
+ Player 2 โญ• + +
+
+ + +
+ + +
+ + +
+
+
P1
+
0
+
+
+
Draws
+
0
+
+
+
P2
+
0
+
+
+ +
Round 1 of 3
+ + +
+ โŒ + Player 1's turn +
+ + +
+
+ ${[0,1,2,3,4,5,6,7,8].map(i => + `` + ).join('')} +
+ + + + + +
+ + + + +
+ + +
+
๐Ÿ†
+
Player 1 Wins!
+
+ P1 + 0 + โ€” + 0 + P2 +
+
0 draws
+
+ + +
+
+ +
+`; } +// โ”€โ”€ 2. Init Function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function initTicTacToe() { - const cells = document.querySelectorAll('.cell'); - const statusText = document.getElementById('ticTacToeStatus'); - const restartBtn = document.getElementById('restartTicTacToe'); - const twoPlayerBtn = document.getElementById('twoPlayerMode'); - const computerBtn = document.getElementById('computerMode'); - let vsComputer = false; - let currentPlayer = 'X'; - let board = ['', '', '', '', '', '', '', '', '']; - let gameActive = true; - - - - const pvpBtn = document.getElementById('pvpMode'); - const aiBtn = document.getElementById('aiMode'); - - pvpBtn.addEventListener('click', () => { - vsComputer = false; - - pvpBtn.classList.add('active-mode'); - aiBtn.classList.remove('active-mode'); - - resetGame(); - statusText.textContent = "2 Player Mode"; + // Win combos + const WINS = [ + [0,1,2],[3,4,5],[6,7,8], // rows + [0,3,6],[1,4,7],[2,5,8], // cols + [0,4,8],[2,4,6] // diagonals + ]; + + // Win-line centre coordinates (column, row) in 0-2 grid space + const WIN_COORDS = [ + [[0,0],[2,0]], [[0,1],[2,1]], [[0,2],[2,2]], // rows + [[0,0],[0,2]], [[1,0],[1,2]], [[2,0],[2,2]], // cols + [[0,0],[2,2]], [[2,0],[0,2]] // diagonals + ]; + + // โ”€โ”€ State โ”€โ”€ + let mode = "2p"; + let difficulty = "easy"; + let maxRounds = 3; + let p1 = "Player 1"; + let p2 = "Player 2"; + let scores = { p1:0, p2:0, draws:0 }; + let board = []; + let current = "X"; // "X" | "O" + let round = 1; + let gameOver = false; + + // โ”€โ”€ Helpers โ”€โ”€ + function qs(sel, ctx) { return (ctx||document).querySelector(sel); } + + // Show one of the three screens + function showScreen(id) { + ["ttt-setup","ttt-game","ttt-final"].forEach(s => { + const el = document.getElementById(s); + if (el) { + el.classList.toggle("ttt-screen--active", s === id); + } }); - - aiBtn.addEventListener('click', () => { - vsComputer = true; - - aiBtn.classList.add('active-mode'); - pvpBtn.classList.remove('active-mode'); - - resetGame(); - statusText.textContent = "Playing vs Computer"; + } + + // Pill-toggle group helper + function initPillGroup(groupId, onChange) { + const grp = document.getElementById(groupId); + if (!grp) return; + grp.querySelectorAll(".ttt-pill").forEach(btn => { + btn.addEventListener("click", () => { + grp.querySelectorAll(".ttt-pill").forEach(b => b.classList.remove("ttt-pill--on")); + btn.classList.add("ttt-pill--on"); + onChange(btn.dataset.val); + }); }); - - const winningCombinations = [ - [0,1,2], - [3,4,5], - [6,7,8], - [0,3,6], - [1,4,7], - [2,5,8], - [0,4,8], - [2,4,6] - ]; - - function checkWinner() { - for (let combo of winningCombinations) { - const [a, b, c] = combo; - - if ( - board[a] && - board[a] === board[b] && - board[a] === board[c] - ) { - statusText.textContent = `๐ŸŽ‰ Player ${board[a]} wins!`; - gameActive = false; - return; - } - } - - if (!board.includes('')) { - statusText.textContent = "๐Ÿค It's a draw!"; - gameActive = false; - } + } + + // โ”€โ”€ Wire up Setup โ”€โ”€ + initPillGroup("ttt-mode-group", val => { + mode = val; + const diffGroup = document.getElementById("ttt-diff-group"); + const p2box = document.getElementById("ttt-p2-box"); + const p2inp = document.getElementById("ttt-p2"); + if (val === "ai") { + diffGroup.style.display = "block"; + p2box.classList.add("ttt-dimmed"); + p2inp.disabled = true; + p2inp.placeholder = "Computer ๐Ÿค–"; + } else { + diffGroup.style.display = "none"; + p2box.classList.remove("ttt-dimmed"); + p2inp.disabled = false; + p2inp.placeholder = "Player 2"; } + }); + + initPillGroup("ttt-diff-pills", val => { difficulty = val; }); + initPillGroup("ttt-rounds-group", val => { maxRounds = parseInt(val); }); + + // Start button + const startBtn = document.getElementById("ttt-start"); + if (startBtn) { + startBtn.addEventListener("click", () => { + p1 = (document.getElementById("ttt-p1").value.trim()) || "Player 1"; + p2 = mode === "ai" + ? "Computer ๐Ÿค–" + : ((document.getElementById("ttt-p2").value.trim()) || "Player 2"); + scores = { p1:0, p2:0, draws:0 }; + round = 1; + newRound(); + showScreen("ttt-game"); + }); + } + + // Back / Menu buttons + const backBtn = document.getElementById("ttt-back"); + if (backBtn) backBtn.addEventListener("click", () => showScreen("ttt-setup")); + + const menuBtn = document.getElementById("ttt-menu"); + if (menuBtn) menuBtn.addEventListener("click", () => showScreen("ttt-setup")); + + // Rematch button + const rematchBtn = document.getElementById("ttt-rematch"); + if (rematchBtn) { + rematchBtn.addEventListener("click", () => { + scores = { p1:0, p2:0, draws:0 }; + round = 1; + newRound(); + showScreen("ttt-game"); + }); + } + + // Next-round button + const nextBtn = document.getElementById("ttt-next"); + if (nextBtn) { + nextBtn.addEventListener("click", () => { + const majority = Math.ceil(maxRounds / 2); + const matchDone = round >= maxRounds + || scores.p1 >= majority + || scores.p2 >= majority; + if (matchDone) { + renderFinal(); + showScreen("ttt-final"); + } else { + round++; + newRound(); + } + }); + } + + // โ”€โ”€ Round management โ”€โ”€ + function newRound() { + board = Array(9).fill(null); + current = "X"; + gameOver = false; + + // Reset cells + document.querySelectorAll(".ttt-cell").forEach(c => { + c.textContent = ""; + c.className = "ttt-cell"; + c.disabled = false; + }); - cells.forEach(cell => { - cell.addEventListener('click', () => { - const index = cell.dataset.cell; + // Hide result overlay + const overlay = document.getElementById("ttt-result-overlay"); + if (overlay) overlay.style.display = "none"; - if (board[index] || !gameActive) return; + // Clear win line + clearWinLine(); - board[index] = currentPlayer; - cell.textContent = currentPlayer; + // Update scoreboard + syncScoreboard(); - checkWinner(); + // Round label + const tag = document.getElementById("ttt-round-tag"); + if (tag) { + tag.textContent = maxRounds === 1 + ? "Single Round" + : `Round ${round} of ${maxRounds}`; + } - if (vsComputer && gameActive && currentPlayer === 'X') { - currentPlayer = 'O'; - statusText.textContent = "Computer's turn"; + refreshTurnBanner(); - setTimeout(() => { - computerMove(); - }, 500); + // If AI goes first (not default, but safe to handle) + if (mode === "ai" && current === "O") { + lockBoard(true); + setTimeout(aiTurn, 600); + } + } + + function syncScoreboard() { + const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; + set("ttt-sn1", p1); + set("ttt-sn2", p2); + set("ttt-sv1", scores.p1); + set("ttt-sv2", scores.p2); + set("ttt-svd", scores.draws); + } + + function refreshTurnBanner() { + const name = current === "X" ? p1 : p2; + const sym = current === "X" ? "X" : "O"; + const symEl = document.getElementById("ttt-turn-sym"); + const nameEl = document.getElementById("ttt-turn-name"); + const banner = document.getElementById("ttt-turn-banner"); + if (symEl) symEl.textContent = sym; + if (nameEl) nameEl.textContent = name; + if (banner) { + banner.classList.toggle("ttt-turn-banner--x", current === "X"); + banner.classList.toggle("ttt-turn-banner--o", current === "O"); + } + } - return; - } + // โ”€โ”€ Cell clicks โ”€โ”€ + document.querySelectorAll(".ttt-cell").forEach(cell => { + cell.addEventListener("click", () => { + const i = parseInt(cell.dataset.i); + if (gameOver || board[i]) return; + if (mode === "ai" && current === "O") return; // AI's turn - if (gameActive) { - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; - statusText.textContent = `Player ${currentPlayer}'s turn`; - } - }); + placeMove(i, current); + afterMove(); }); - - function resetGame() { - board = ['', '', '', '', '', '', '', '', '']; - gameActive = true; - currentPlayer = 'X'; - - cells.forEach(cell => { - cell.textContent = ''; - }); - - statusText.textContent = "Player X's turn"; + }); + + function placeMove(i, sym) { + board[i] = sym; + const cell = document.querySelector(`.ttt-cell[data-i="${i}"]`); + if (!cell) return; + cell.textContent = sym; + cell.classList.add(sym === "X" ? "ttt-cell--x" : "ttt-cell--o"); + cell.disabled = true; + } + + function afterMove() { + const win = getWinner(board); + if (win) { endRound(win); return; } + if (board.every(c=>c)){ endRound(null); return; } + + current = current === "X" ? "O" : "X"; + refreshTurnBanner(); + + if (mode === "ai" && current === "O") { + lockBoard(true); + setTimeout(aiTurn, 480 + Math.random()*200); } + } - function computerMove() { - let emptyCells = []; - - board.forEach((cell, index) => { - if (cell === '') { - emptyCells.push(index); - } - }); - - if (emptyCells.length === 0) return; + function lockBoard(on) { + document.querySelectorAll(".ttt-cell").forEach(c => { + if (!board[parseInt(c.dataset.i)]) c.disabled = on; + }); + } + + // โ”€โ”€ Win detection โ”€โ”€ + function getWinner(b) { + for (let idx=0; idx { + const cell = document.querySelector(`.ttt-cell[data-i="${i}"]`); + if (cell) cell.classList.add("ttt-cell--win"); + }); + // Draw SVG win line + drawWinLine(win.coordIdx); + + const winnerName = win.sym === "X" ? p1 : p2; + if (win.sym === "X") scores.p1++; else scores.p2++; + syncScoreboard(); + + setResult("๐Ÿ†", `${winnerName} wins this round!`); + } else { + scores.draws++; + syncScoreboard(); + setResult("๐Ÿค", "It's a draw!"); + } - const randomIndex = - emptyCells[Math.floor(Math.random() * emptyCells.length)]; + // Update Next button label + setTimeout(() => { + const majority = Math.ceil(maxRounds / 2); + const matchDone = round >= maxRounds + || scores.p1 >= majority + || scores.p2 >= majority; + const nextBtn = document.getElementById("ttt-next"); + if (nextBtn) nextBtn.textContent = matchDone ? "See Results โ†’" : "Next Round โ†’"; + + const overlay = document.getElementById("ttt-result-overlay"); + if (overlay) overlay.style.display = "flex"; + }, 600); + } + + function setResult(emoji, text) { + const e = document.getElementById("ttt-res-emoji"); + const t = document.getElementById("ttt-res-text"); + if (e) e.textContent = emoji; + if (t) t.textContent = text; + } + + // โ”€โ”€ Win-line SVG โ”€โ”€ + // Grid cells are (col, row) 0-indexed; centre of cell = col+0.5, row+0.5 + function drawWinLine(comboIdx) { + const line = document.getElementById("ttt-win-line"); + const svg = document.getElementById("ttt-win-svg"); + if (!line || !svg) return; + + const [[c1,r1],[c2,r2]] = WIN_COORDS[comboIdx]; + line.setAttribute("x1", c1 + 0.5); + line.setAttribute("y1", r1 + 0.5); + line.setAttribute("x2", c2 + 0.5); + line.setAttribute("y2", r2 + 0.5); + line.setAttribute("opacity", "1"); + svg.classList.add("ttt-win-svg--visible"); +} + function clearWinLine() { + const line = document.getElementById("ttt-win-line"); + const svg = document.getElementById("ttt-win-svg"); + if (line) line.setAttribute("opacity","0"); + if (svg) svg.classList.remove("ttt-win-svg--visible"); + } + + // โ”€โ”€ Final screen โ”€โ”€ + function renderFinal() { + const set = (id,v) => { const el=document.getElementById(id); if(el) el.textContent=v; }; + set("ttt-fp1", p1); + set("ttt-fp2", p2); + set("ttt-fp1s", scores.p1); + set("ttt-fp2s", scores.p2); + set("ttt-final-draws", `${scores.draws} draw${scores.draws!==1?"s":""}`); + + let title; + if (scores.p1 > scores.p2) title = `๐Ÿ† ${p1} wins the match!`; + else if (scores.p2 > scores.p1) title = `๐Ÿ† ${p2} wins the match!`; + else title = "๐Ÿค The match is tied!"; + set("ttt-final-title", title); + } + + // โ”€โ”€ AI engines โ”€โ”€ + function freeCells(b) { + return b.reduce((acc,v,i) => { if(!v) acc.push(i); return acc; }, []); + } + + function checkWinFor(b, sym) { + return WINS.some(([a,x,c]) => b[a]===sym && b[x]===sym && b[c]===sym); + } + + function minimax(b, isMax, alpha, beta, depth) { + if (checkWinFor(b,"O")) return 10 - depth; + if (checkWinFor(b,"X")) return depth - 10; + if (b.every(c=>c)) return 0; + + const moves = freeCells(b); + if (isMax) { + let best = -Infinity; + for (const m of moves) { + b[m] = "O"; + best = Math.max(best, minimax(b, false, alpha, beta, depth+1)); + b[m] = null; + alpha = Math.max(alpha, best); + if (beta <= alpha) break; + } + return best; + } else { + let best = Infinity; + for (const m of moves) { + b[m] = "X"; + best = Math.min(best, minimax(b, true, alpha, beta, depth+1)); + b[m] = null; + beta = Math.min(beta, best); + if (beta <= alpha) break; + } + return best; + } + } - board[randomIndex] = 'O'; - cells[randomIndex].textContent = 'O'; + function chooseMove(b, diff) { + const moves = freeCells(b); + if (!moves.length) return null; - checkWinner(); + // Easy โ€” random + if (diff === "easy") return moves[Math.floor(Math.random()*moves.length)]; - if (gameActive) { - currentPlayer = 'X'; - statusText.textContent = "Player X's turn"; - } + // Medium โ€” win โ†’ block โ†’ center/corners + if (diff === "medium") { + for (const m of moves) { b[m]="O"; if(checkWinFor(b,"O")){b[m]=null;return m;} b[m]=null; } + for (const m of moves) { b[m]="X"; if(checkWinFor(b,"X")){b[m]=null;return m;} b[m]=null; } + for (const p of [4,0,2,6,8,1,3,5,7]) { if(!b[p]) return p; } + return moves[0]; } - restartBtn.addEventListener('click', resetGame); -} \ No newline at end of file + // Hard โ€” minimax + let bestScore=-Infinity, bestMove=moves[0]; + for (const m of moves) { + b[m]="O"; + const s = minimax(b, false, -Infinity, Infinity, 0); + b[m]=null; + if (s > bestScore) { bestScore=s; bestMove=m; } + } + return bestMove; + } + + function aiTurn() { + if (gameOver) return; + const move = chooseMove([...board], difficulty); // pass copy so minimax doesn't corrupt state + lockBoard(false); + if (move !== null) placeMove(move, "O"); + afterMove(); + } + +} // end initTicTacToe \ No newline at end of file diff --git a/web-app/math.html b/web-app/math.html index 46a91a0..71c5785 100644 --- a/web-app/math.html +++ b/web-app/math.html @@ -91,44 +91,103 @@ - + - - +
+
- -
- -
-
@@ -267,7 +326,9 @@
-
+ - -
- -
+ + + +
@@ -141,6 +200,13 @@

+
Caesar Cipher
+ +

Caesar Cipher

+

Encrypt & decrypt with a shift!

+ +
+
Morse Code

Morse Code

@@ -225,8 +291,11 @@