summaryrefslogtreecommitdiffstats
path: root/assets/js
diff options
context:
space:
mode:
Diffstat (limited to 'assets/js')
-rw-r--r--assets/js/404.js54
-rw-r--r--assets/js/article-lazy.js34
-rw-r--r--assets/js/code-copy.js79
-rw-r--r--assets/js/contact-form.js45
-rw-r--r--assets/js/copy-code.js42
-rw-r--r--assets/js/filters.js37
-rw-r--r--assets/js/form-components.js127
-rw-r--r--assets/js/fortune.js9
-rw-r--r--assets/js/glitch.js30
-rw-r--r--assets/js/hamburger.js83
-rw-r--r--assets/js/lightbox.js31
-rw-r--r--assets/js/main.js18
-rw-r--r--assets/js/matrix-rain.js179
-rw-r--r--assets/js/menu.js112
-rw-r--r--assets/js/not-found-page.js9
-rw-r--r--assets/js/photo-utils.js476
-rw-r--r--assets/js/progress-bar.js20
-rw-r--r--assets/js/reading-progress.js29
-rw-r--r--assets/js/scroll-reveal.js40
-rw-r--r--assets/js/search.js134
-rw-r--r--assets/js/share-sidebar.js51
-rw-r--r--assets/js/tag-cloud-spiral.js122
-rw-r--r--assets/js/theme-toggle.js116
-rw-r--r--assets/js/typing.js69
24 files changed, 881 insertions, 1065 deletions
diff --git a/assets/js/404.js b/assets/js/404.js
deleted file mode 100644
index c26c218..0000000
--- a/assets/js/404.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * 404.js
- * Quote randomization and terminal animation for 404 page
- */
-
-(function() {
- 'use strict';
-
- const quotes = [
- 'The page you are looking for doesn\'t exist. But that\'s okay, nothing exists until you find it.',
- 'A 404 is just a redirect to a new beginning.',
- 'You found a secret path. Sadly, it leads nowhere.',
- 'This page chose to remain unknown.',
- 'In the quantum realm, this page exists and doesn\'t exist simultaneously.',
- 'Sometimes the best discoveries are the ones we never intended to find.',
- ];
-
- const quoteText = document.getElementById('quote-text');
- const quoteAuthor = document.getElementById('quote-author');
- const terminalFiles = document.getElementById('terminal-files');
-
- if (quoteText && quoteAuthor) {
- const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
- quoteText.textContent = randomQuote;
- quoteAuthor.textContent = '— 404 Philosopher';
- }
-
- if (terminalFiles) {
- const files = [
- 'post-01-security.md',
- 'post-02-web-dev.md',
- 'post-03-bash-tips.md',
- 'about.md',
- 'contact.md',
- ];
-
- files.forEach((file) => {
- const line = document.createElement('div');
- line.textContent = file;
- terminalFiles.appendChild(line);
- });
- }
-
- // Listen for search (if implemented)
- const searchBtn = document.querySelector('.search-box button');
- if (searchBtn) {
- searchBtn.addEventListener('click', () => {
- const input = document.querySelector('.search-box input');
- if (input && input.value) {
- window.location.href = `/articles/?search=${encodeURIComponent(input.value)}`;
- }
- });
- }
-})();
diff --git a/assets/js/article-lazy.js b/assets/js/article-lazy.js
new file mode 100644
index 0000000..64ca862
--- /dev/null
+++ b/assets/js/article-lazy.js
@@ -0,0 +1,34 @@
+document.addEventListener('DOMContentLoaded', function () {
+ var timeline = document.querySelector('ol.timeline');
+ if (!timeline) return;
+
+ // Progressive enhancement: activates CSS hidden state
+ timeline.classList.add('js-lazy-timeline');
+
+ var items = Array.prototype.slice.call(
+ timeline.querySelectorAll('.timeline-item')
+ );
+
+ function reveal(item) {
+ item.classList.add('is-visible');
+ }
+
+ var observer = new IntersectionObserver(
+ function (entries) {
+ entries.forEach(function (entry) {
+ if (entry.isIntersecting) {
+ reveal(entry.target);
+ observer.unobserve(entry.target);
+ }
+ });
+ },
+ {
+ rootMargin: '-80px 0px 0px 0px',
+ threshold: 0.12,
+ }
+ );
+
+ items.forEach(function (item) {
+ observer.observe(item);
+ });
+});
diff --git a/assets/js/code-copy.js b/assets/js/code-copy.js
new file mode 100644
index 0000000..8591436
--- /dev/null
+++ b/assets/js/code-copy.js
@@ -0,0 +1,79 @@
+(function () {
+ var LANG_NAMES = {
+ bash: 'Shell', sh: 'Shell', shell: 'Shell', zsh: 'Shell',
+ js: 'JavaScript', javascript: 'JavaScript',
+ ts: 'TypeScript', typescript: 'TypeScript',
+ go: 'Go',
+ py: 'Python', python: 'Python',
+ rs: 'Rust', rust: 'Rust',
+ html: 'HTML',
+ css: 'CSS',
+ toml: 'TOML',
+ yaml: 'YAML', yml: 'YAML',
+ json: 'JSON',
+ sql: 'SQL',
+ md: 'Markdown', markdown: 'Markdown',
+ c: 'C',
+ cpp: 'C++', 'c++': 'C++',
+ java: 'Java',
+ php: 'PHP',
+ ruby: 'Ruby', rb: 'Ruby',
+ swift: 'Swift',
+ kotlin: 'Kotlin', kt: 'Kotlin',
+ dockerfile: 'Dockerfile',
+ makefile: 'Makefile',
+ text: 'Text', txt: 'Text',
+ };
+
+ function prettyName(lang) {
+ if (!lang) return '';
+ var key = lang.toLowerCase();
+ return LANG_NAMES[key] || (lang.charAt(0).toUpperCase() + lang.slice(1));
+ }
+
+ function getCodeText(wrapper) {
+ var el = wrapper.querySelector('.lntd:last-child code')
+ || wrapper.querySelector('.code-body code')
+ || wrapper.querySelector('.code-body pre');
+ return el ? el.innerText : '';
+ }
+
+ function initBlock(wrapper) {
+ var header = wrapper.querySelector('.code-header');
+ if (header) {
+ var label = wrapper.querySelector('.code-lang-label');
+ if (label) label.textContent = prettyName(header.getAttribute('data-lang') || '');
+ }
+
+ var btn = wrapper.querySelector('[data-copy-target]');
+ if (!btn) return;
+
+ btn.addEventListener('click', function () {
+ var text = getCodeText(wrapper);
+ if (!text) return;
+
+ navigator.clipboard.writeText(text).then(function () {
+ var copyIcon = btn.querySelector('.icon-copy');
+ var checkIcon = btn.querySelector('.icon-check');
+ var liveRegion = wrapper.querySelector('.code-copy-status');
+ if (copyIcon) copyIcon.classList.add('hidden');
+ if (checkIcon) checkIcon.classList.remove('hidden');
+ btn.classList.add('is-copied');
+ if (liveRegion) liveRegion.textContent = 'Code copied to clipboard.';
+
+ setTimeout(function () {
+ if (copyIcon) copyIcon.classList.remove('hidden');
+ if (checkIcon) checkIcon.classList.add('hidden');
+ btn.classList.remove('is-copied');
+ if (liveRegion) liveRegion.textContent = '';
+ }, 2000);
+ }).catch(function () {
+ // silent fail for insecure contexts
+ });
+ });
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ document.querySelectorAll('.code-block-wrapper').forEach(initBlock);
+ });
+})();
diff --git a/assets/js/contact-form.js b/assets/js/contact-form.js
new file mode 100644
index 0000000..4fa8f55
--- /dev/null
+++ b/assets/js/contact-form.js
@@ -0,0 +1,45 @@
+document.addEventListener('alpine:init', () => {
+ Alpine.data('contactForm', () => ({
+ formData: {
+ name: '',
+ email: '',
+ message: ''
+ },
+ isSubmitting: false,
+ statusMessage: '',
+ statusClass: '',
+
+ async submitContactForm() {
+ this.isSubmitting = true;
+ this.statusMessage = '';
+ this.statusClass = '';
+
+ try {
+ const response = await fetch('/contact.php', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(this.formData)
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ this.statusMessage = 'Message sent successfully!';
+ this.statusClass = 'bg-green-100 text-green-800 border border-green-300';
+ this.formData = { name: '', email: '', message: '' };
+ } else {
+ this.statusMessage = data.error || 'An error occurred. Please try again.';
+ this.statusClass = 'bg-red-100 text-red-800 border border-red-300';
+ }
+ } catch (error) {
+ this.statusMessage = 'An error occurred. Please try again.';
+ this.statusClass = 'bg-red-100 text-red-800 border border-red-300';
+ console.error('Form submission error:', error);
+ } finally {
+ this.isSubmitting = false;
+ }
+ }
+ }));
+});
diff --git a/assets/js/copy-code.js b/assets/js/copy-code.js
deleted file mode 100644
index a18bf6c..0000000
--- a/assets/js/copy-code.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// copy-code.js
-(function() {
- // Add copy button to all code blocks
- const codeBlocks = document.querySelectorAll('pre, .highlight');
-
- codeBlocks.forEach(block => {
- // Create copy button
- const btn = document.createElement('button');
- btn.className = 'code-copy-btn';
- btn.textContent = 'copy';
- btn.type = 'button';
- btn.setAttribute('aria-label', 'Copy code');
-
- // Get code text
- const code = block.querySelector('code');
- const text = code ? code.textContent : block.textContent;
-
- // Copy on click
- btn.addEventListener('click', async function() {
- try {
- await navigator.clipboard.writeText(text);
-
- // Show feedback
- const originalText = btn.textContent;
- btn.textContent = 'copied!';
- btn.classList.add('copied');
-
- setTimeout(() => {
- btn.textContent = originalText;
- btn.classList.remove('copied');
- }, 2000);
- } catch (err) {
- console.error('Failed to copy:', err);
- btn.textContent = 'error';
- }
- });
-
- // Add button to block
- block.style.position = 'relative';
- block.appendChild(btn);
- });
-})();
diff --git a/assets/js/filters.js b/assets/js/filters.js
deleted file mode 100644
index f7fa6a6..0000000
--- a/assets/js/filters.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * filters.js
- * Article filtering by type on the articles page
- */
-
-(function() {
- 'use strict';
-
- const filterBtns = document.querySelectorAll('.filter-btn');
- const timelineItems = document.querySelectorAll('.timeline-item');
-
- filterBtns.forEach((btn) => {
- btn.addEventListener('click', () => {
- const filter = btn.getAttribute('data-filter');
-
- // Update active button
- filterBtns.forEach((b) => b.classList.remove('active'));
- btn.classList.add('active');
-
- // Filter articles
- timelineItems.forEach((item) => {
- const type = item.getAttribute('data-type');
-
- if (filter === 'all' || type === filter) {
- item.classList.add('visible');
- } else {
- item.classList.remove('visible');
- }
- });
- });
- });
-
- // Show all on load
- timelineItems.forEach((item) => {
- item.classList.add('visible');
- });
-})();
diff --git a/assets/js/form-components.js b/assets/js/form-components.js
new file mode 100644
index 0000000..ffa4260
--- /dev/null
+++ b/assets/js/form-components.js
@@ -0,0 +1,127 @@
+// Form component utilities and Alpine.js data
+
+export function formComponentsData() {
+ return {
+ // Modal states
+ showAlertModal: false,
+ showConfirmModal: false,
+ showContentModal: false,
+
+ // Toast notification state
+ toasts: [],
+
+ // Handle confirm modal action
+ handleConfirm() {
+ this.showConfirmModal = false;
+ this.showToast('success', 'Action confirmed!');
+ },
+
+ // Show toast notification
+ showToast(type = 'success', message = null) {
+ const messages = {
+ success: 'Operation completed successfully!',
+ error: 'An error occurred. Please try again.',
+ info: 'Here is some information.',
+ warning: 'Please be careful with this action.'
+ };
+
+ const toastMessage = message || messages[type] || messages.success;
+ const toastId = Date.now();
+
+ // Add toast to list
+ this.toasts.push({
+ id: toastId,
+ type: type,
+ message: toastMessage
+ });
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => {
+ this.toasts = this.toasts.filter(t => t.id !== toastId);
+ }, 5000);
+ },
+
+ // Remove toast manually
+ removeToast(id) {
+ this.toasts = this.toasts.filter(t => t.id !== id);
+ },
+
+ // Form validation utilities
+ validateEmail(email) {
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return regex.test(email);
+ },
+
+ validatePassword(password) {
+ return password.length >= 8;
+ },
+
+ // Auto-expand textarea
+ autoExpandTextarea(event) {
+ const textarea = event.target;
+ textarea.style.height = 'auto';
+ textarea.style.height = (textarea.scrollHeight) + 'px';
+ }
+ };
+}
+
+// Toast container component for Alpine.js
+export function renderToastContainer(Alpine) {
+ if (!Alpine) return;
+
+ // This can be used in templates via Alpine
+ window.formUtils = {
+ formatCharCount(current, max) {
+ if (max) {
+ return `${current}/${max}`;
+ }
+ return current;
+ },
+
+ isCharCountWarning(current, max) {
+ if (!max) return false;
+ return current > (max * 0.8);
+ },
+
+ isCharCountError(current, max) {
+ if (!max) return false;
+ return current >= max;
+ }
+ };
+}
+
+// Focus Trap for Modals - Week 5
+function createFocusTrap(modalElement) {
+ const focusableElements = modalElement.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ modalElement.addEventListener('keydown', (e) => {
+ if (e.key !== 'Tab') return;
+
+ if (e.shiftKey) {
+ // Shift + Tab
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement.focus();
+ }
+ } else {
+ // Tab
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement.focus();
+ }
+ }
+ });
+
+ // Set initial focus
+ firstElement.focus();
+}
+
+// Export for use in Alpine.js
+window.createFocusTrap = createFocusTrap;
diff --git a/assets/js/fortune.js b/assets/js/fortune.js
new file mode 100644
index 0000000..d4f981b
--- /dev/null
+++ b/assets/js/fortune.js
@@ -0,0 +1,9 @@
+(function() {
+ const el = document.getElementById('fortune-quote');
+ if (!el) return;
+ const quotes = JSON.parse(el.dataset.quotes);
+ if (!quotes || quotes.length === 0) return;
+ const q = quotes[Math.floor(Math.random() * quotes.length)];
+ el.querySelector('.fortune-text').textContent = '"' + q.text + '"';
+ el.querySelector('.fortune-author').textContent = '— ' + q.author;
+})();
diff --git a/assets/js/glitch.js b/assets/js/glitch.js
deleted file mode 100644
index 85f8a00..0000000
--- a/assets/js/glitch.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * glitch.js
- * Random glitch effect on .hero-name every 4-11 seconds
- */
-
-export function initGlitch() {
- 'use strict';
-
- const heroName = document.querySelector('.hero-name');
- if (!heroName) return;
-
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
-
- function triggerGlitch() {
- heroName.classList.add('is-glitching');
- setTimeout(() => {
- heroName.classList.remove('is-glitching');
- }, 450);
- }
-
- function scheduleNextGlitch() {
- const delay = Math.random() * 7000 + 4000; // 4-11 seconds
- setTimeout(() => {
- triggerGlitch();
- scheduleNextGlitch();
- }, delay);
- }
-
- scheduleNextGlitch();
-}
diff --git a/assets/js/hamburger.js b/assets/js/hamburger.js
deleted file mode 100644
index 1d27633..0000000
--- a/assets/js/hamburger.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * hamburger.js
- * Hamburger menu toggle with focus trap and keyboard navigation
- */
-
-export function initHamburger() {
- 'use strict';
-
- const hamburgerBtn = document.getElementById('hamburger-btn');
- const menuOverlay = document.getElementById('menu-overlay');
- const menuLinks = document.querySelectorAll('.menu-links a');
- const themeSwitch = document.getElementById('theme-switch');
-
- if (!hamburgerBtn || !menuOverlay) return;
-
- // Toggle menu on hamburger click
- hamburgerBtn.addEventListener('click', () => {
- const isOpen = hamburgerBtn.getAttribute('aria-expanded') === 'true';
- setMenuOpen(!isOpen);
- });
-
- // Close menu on overlay click
- menuOverlay.addEventListener('click', (e) => {
- if (e.target === menuOverlay) {
- setMenuOpen(false);
- }
- });
-
- // Close menu on menu link click
- menuLinks.forEach((link) => {
- link.addEventListener('click', () => {
- setMenuOpen(false);
- });
- });
-
- // Close menu on Escape key
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- setMenuOpen(false);
- }
- });
-
- // Focus trap: Tab through menu items and theme switch
- menuOverlay.addEventListener('keydown', (e) => {
- if (e.key !== 'Tab') return;
-
- const focusableElements = Array.from(
- menuOverlay.querySelectorAll('a, button')
- );
- const firstElement = focusableElements[0];
- const lastElement = focusableElements[focusableElements.length - 1];
-
- if (e.shiftKey) {
- if (document.activeElement === firstElement) {
- lastElement.focus();
- e.preventDefault();
- }
- } else {
- if (document.activeElement === lastElement) {
- firstElement.focus();
- e.preventDefault();
- }
- }
- });
-
- function setMenuOpen(isOpen) {
- hamburgerBtn.setAttribute('aria-expanded', isOpen);
- hamburgerBtn.classList.toggle('active', isOpen);
- menuOverlay.classList.toggle('active', isOpen);
-
- if (isOpen) {
- document.body.style.overflow = 'hidden';
- // Focus the first menu link
- const firstLink = menuLinks[0];
- if (firstLink) {
- setTimeout(() => firstLink.focus(), 100);
- }
- } else {
- document.body.style.overflow = '';
- hamburgerBtn.focus();
- }
- }
-}
diff --git a/assets/js/lightbox.js b/assets/js/lightbox.js
deleted file mode 100644
index 81c3613..0000000
--- a/assets/js/lightbox.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * lightbox.js
- * Photo lightbox initialization
- */
-
-(function() {
- 'use strict';
-
- if (typeof PhotoUtils === 'undefined') return;
-
- const photoGrid = document.querySelector('.photo-grid[data-lightbox="true"]');
- if (!photoGrid) return;
-
- const photoCards = photoGrid.querySelectorAll('.photo-card');
- const photosData = [];
-
- photoCards.forEach((card, index) => {
- const figure = card.querySelector('figure');
- const img = card.querySelector('img');
- photosData.push({
- index,
- src: figure.getAttribute('data-src') || img.src,
- alt: figure.getAttribute('data-alt') || img.alt,
- caption: figure.getAttribute('data-caption'),
- location: figure.getAttribute('data-location'),
- });
- });
-
- // Initialize lightbox with PhotoUtils
- PhotoUtils.initLightbox('.photo-grid', photosData);
-})();
diff --git a/assets/js/main.js b/assets/js/main.js
deleted file mode 100644
index ee6b24f..0000000
--- a/assets/js/main.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// main.js
-import './theme-toggle.js';
-import './matrix-rain.js';
-import './progress-bar.js';
-import './copy-code.js';
-import { initHamburger } from './hamburger.js';
-import { initGlitch } from './glitch.js';
-import { initTyping } from './typing.js';
-import { initScrollReveal } from './scroll-reveal.js';
-import { initShareSidebar } from './share-sidebar.js';
-
-document.addEventListener('DOMContentLoaded', () => {
- initHamburger();
- initGlitch();
- initTyping();
- initScrollReveal();
- initShareSidebar();
-});
diff --git a/assets/js/matrix-rain.js b/assets/js/matrix-rain.js
index 8211b40..53c55d8 100644
--- a/assets/js/matrix-rain.js
+++ b/assets/js/matrix-rain.js
@@ -1,56 +1,157 @@
-// matrix-rain.js
-(function () {
- const canvas = document.getElementById('matrix-canvas');
+// Matrix rain background effect
+(function() {
+ // Bail out if user prefers reduced motion
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ return;
+ }
+
+ // Canvas and context
+ let canvas = document.getElementById('matrix-rain');
if (!canvas) return;
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ const ctx = canvas.getContext('2d');
+
+ // State
+ let columns = [];
+ let frameCount = 0;
+ let colors = { accent: '#a855f7', accent2: '#00ff88', bg: '#060b10', head: '#ffffff' };
+
+ // Character set: 30% ASCII, 70% katakana
+ const ASCII = Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i));
+ const KATA = Array.from({ length: 96 }, (_, i) => String.fromCodePoint(0x30a0 + i));
+ const CHARS = shuffle([...ASCII, ...KATA, ...KATA, ...KATA]);
+
+ // Utility: shuffle array
+ function shuffle(arr) {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ return arr;
+ }
+
+ // Utility: convert hex or rgb color to rgba string
+ function hexToRgba(color, alpha) {
+ const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
+ if (rgbMatch) {
+ return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`;
+ }
+ const hex = color.replace('#', '');
+ const r = parseInt(hex.substring(0, 2), 16);
+ const g = parseInt(hex.substring(2, 4), 16);
+ const b = parseInt(hex.substring(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+
+ // Sample CSS variables based on current theme
+ function sampleColors() {
+ const style = getComputedStyle(document.documentElement);
+ const isDark = document.documentElement.classList.contains('theme-dark');
+ colors.accent = style.getPropertyValue('--accent').trim();
+ colors.accent2 = style.getPropertyValue('--accent2').trim();
+ colors.bg = style.getPropertyValue('--bg').trim();
+ // Head char: bright white in dark mode, deep purple-black in light mode
+ colors.head = isDark ? '#ffffff' : '#1a0533';
+ }
- const ctx = canvas.getContext('2d');
- const CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF<>/\\|{}[]$#@!';
- const FS = 14; // font size / column width in px
- const mode = canvas.getAttribute('data-mode') || 'background';
- let cols, drops, raf;
+ // Resize canvas to window dimensions
+ function resizeCanvas() {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+ ctx.font = '14px "JetBrains Mono", monospace';
+ ctx.textBaseline = 'top';
+ initColumns();
+ }
+
+ // Initialize columns for the current canvas width
+ function initColumns() {
+ columns = [];
+ const columnWidth = 14;
+ const columnCount = Math.floor(canvas.width / columnWidth);
- function init() {
- // Use offsetWidth/offsetHeight which works for both fixed and positioned elements
- canvas.width = canvas.offsetWidth || window.innerWidth;
- canvas.height = canvas.offsetHeight || window.innerHeight;
- cols = Math.floor(canvas.width / FS) + 1;
- drops = Array.from({ length: cols }, () => Math.random() * -(canvas.height / FS));
+ for (let i = 0; i < columnCount; i++) {
+ columns.push({
+ x: i * columnWidth,
+ y: -Math.floor(Math.random() * 40), // stagger start above viewport
+ speed: 2 + Math.floor(Math.random() * 3), // 2-4 frames between drops
+ color: Math.random() < 0.6 ? 'accent2' : 'accent', // 60% green, 40% purple
+ charIndex: Math.floor(Math.random() * CHARS.length),
+ length: 8 + Math.floor(Math.random() * 13), // trail length 8-20
+ });
+ }
}
- function getThemeColors() {
- const isDark = !document.documentElement.classList.contains('theme-light');
- return {
- bgFill: isDark ? 'rgba(6, 11, 16, 0.07)' : 'rgba(240, 244, 248, 0.07)',
- };
+ // Set up MutationObserver for theme switching
+ function setupThemeObserver() {
+ const observer = new MutationObserver(function(mutations) {
+ for (const m of mutations) {
+ if (m.attributeName === 'class') {
+ sampleColors();
+ break;
+ }
+ }
+ });
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['class'],
+ });
}
- function tick() {
- const colors = getThemeColors();
- ctx.fillStyle = colors.bgFill;
+ // Main animation loop
+ function drawFrame() {
+ frameCount++;
+
+ // Fade layer: semi-transparent background fill
+ ctx.fillStyle = hexToRgba(colors.bg, 0.085);
ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.font = `${FS}px "JetBrains Mono", monospace`;
- for (let i = 0; i < cols; i++) {
- const char = CHARS[Math.floor(Math.random() * CHARS.length)];
- // Occasional bright green "head", otherwise purple
- ctx.fillStyle = Math.random() > 0.96 ? '#00ff88' : '#a855f7';
- ctx.fillText(char, i * FS, drops[i] * FS);
+ // Draw each column
+ for (const col of columns) {
+ // Skip if not time to drop yet (per-column throttle)
+ if (frameCount % col.speed !== 0) continue;
+
+ // Draw explicit trail in column color
+ ctx.fillStyle = colors[col.color];
+ for (let i = 1; i <= col.length; i++) {
+ const trailY = (col.y - i) * 14;
+ if (trailY < 0) continue;
+ const trailCharIndex = (col.charIndex - i + CHARS.length) % CHARS.length;
+ ctx.fillText(CHARS[trailCharIndex], col.x, trailY);
+ }
+
+ // Draw head character (bright)
+ ctx.fillStyle = colors.head;
+ const headCharIndex = col.charIndex % CHARS.length;
+ ctx.fillText(CHARS[headCharIndex], col.x, col.y * 14);
+
+ // Advance column
+ col.y++;
+ col.charIndex = (col.charIndex + 1) % CHARS.length;
- if (drops[i] * FS > canvas.height && Math.random() > 0.975) {
- drops[i] = Math.random() * -20;
+ // Reset when scrolled off screen
+ if (col.y * 14 > canvas.height + col.length * 14) {
+ col.y = -Math.floor(Math.random() * 20);
+ col.charIndex = Math.floor(Math.random() * CHARS.length);
+ col.color = Math.random() < 0.6 ? 'accent2' : 'accent';
}
- drops[i] += 0.5;
}
- raf = requestAnimationFrame(tick);
+
+ requestAnimationFrame(drawFrame);
}
- init();
- window.addEventListener('resize', () => { cancelAnimationFrame(raf); init(); tick(); }, { passive: true });
- document.addEventListener('visibilitychange', () => {
- if (document.hidden) cancelAnimationFrame(raf); else tick();
+ // Initialize
+ sampleColors();
+ resizeCanvas();
+ setupThemeObserver();
+
+ // Debounced resize handler
+ let resizeTimer;
+ window.addEventListener('resize', function() {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(resizeCanvas, 150);
});
- tick();
- window.MatrixRain = { init, tick };
+ // Start animation when fonts are ready
+ document.fonts.ready.then(function() {
+ requestAnimationFrame(drawFrame);
+ });
})();
diff --git a/assets/js/menu.js b/assets/js/menu.js
new file mode 100644
index 0000000..3f32642
--- /dev/null
+++ b/assets/js/menu.js
@@ -0,0 +1,112 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const menuToggle = document.getElementById('menu-toggle');
+ const menuOverlay = document.getElementById('menu-overlay');
+ const menuPanel = document.getElementById('hamburger-menu');
+
+ function openMenu() {
+ if (!menuOverlay || !menuPanel) return;
+
+ // Show overlay
+ menuOverlay.classList.remove('opacity-0');
+ menuOverlay.classList.remove('invisible');
+
+ // Slide menu in
+ menuPanel.classList.remove('translate-x-full');
+
+ // Manage accessibility
+ menuToggle.setAttribute('aria-expanded', 'true');
+ menuPanel.removeAttribute('aria-hidden');
+
+ // Control body overflow
+ document.body.style.overflow = 'hidden';
+
+ // Focus first focusable element in menu
+ const firstFocusable = menuPanel.querySelector('a, button');
+ if (firstFocusable) {
+ setTimeout(() => firstFocusable.focus(), 50);
+ }
+ }
+
+ function closeMenu() {
+ if (!menuOverlay || menuOverlay.classList.contains('opacity-0')) return;
+
+ // Hide overlay
+ menuOverlay.classList.add('opacity-0');
+ menuOverlay.classList.add('invisible');
+
+ // Slide menu out
+ menuPanel.classList.add('translate-x-full');
+
+ // Manage accessibility
+ menuToggle.setAttribute('aria-expanded', 'false');
+ menuPanel.setAttribute('aria-hidden', 'true');
+
+ // Restore body overflow
+ document.body.style.overflow = '';
+
+ // Return focus to toggle button
+ menuToggle.focus();
+ }
+
+ function toggleMenu() {
+ if (menuOverlay && menuOverlay.classList.contains('opacity-0')) {
+ openMenu();
+ } else {
+ closeMenu();
+ }
+ }
+
+ // Toggle menu when clicking the hamburger button
+ if (menuToggle) {
+ menuToggle.addEventListener('click', toggleMenu);
+ }
+
+ // Close menu when clicking on the overlay
+ if (menuOverlay) {
+ menuOverlay.addEventListener('click', (e) => {
+ if (e.target === menuOverlay) {
+ closeMenu();
+ }
+ });
+ }
+
+ // Close menu when clicking menu items
+ const menuLinks = document.querySelectorAll('#hamburger-menu a, #hamburger-menu button');
+ menuLinks.forEach(link => {
+ link.addEventListener('click', closeMenu);
+ });
+
+ // Close menu on Escape key
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && menuOverlay && !menuOverlay.classList.contains('opacity-0')) {
+ closeMenu();
+ }
+ });
+
+ // Focus trap: keep tab within menu when open
+ if (menuPanel) {
+ menuPanel.addEventListener('keydown', (e) => {
+ if (e.key !== 'Tab') return;
+
+ const focusableElements = menuPanel.querySelectorAll('a, button, [tabindex]:not([tabindex="-1"])');
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+ const isMenuOpen = !menuOverlay.classList.contains('opacity-0');
+
+ if (!isMenuOpen) return;
+
+ // Shift+Tab on first element: move to last
+ if (e.shiftKey && document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement.focus();
+ }
+ // Tab on last element: move to first
+ else if (!e.shiftKey && document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement.focus();
+ }
+ });
+ }
+});
diff --git a/assets/js/not-found-page.js b/assets/js/not-found-page.js
new file mode 100644
index 0000000..2b4f676
--- /dev/null
+++ b/assets/js/not-found-page.js
@@ -0,0 +1,9 @@
+// 404 page: initialize shared notFoundPage Alpine component
+document.addEventListener('alpine:init', () => {
+ // Ensure search index is preloaded on 404 page
+ const notFoundElement = document.querySelector('[x-data*="notFoundPage"]');
+ if (notFoundElement && notFoundElement.__x) {
+ notFoundElement.__x.$data.init();
+ }
+ console.log('404 page initialized with shared search functionality');
+});
diff --git a/assets/js/photo-utils.js b/assets/js/photo-utils.js
deleted file mode 100644
index 32ede76..0000000
--- a/assets/js/photo-utils.js
+++ /dev/null
@@ -1,476 +0,0 @@
-(function() {
- 'use strict';
-
- // Internal state
- let currentPhotoIndex = 0;
- let photosData = [];
- let isOpen = false;
- let exifData = {};
- let options = {
- swipeEnabled: true,
- keyboardEnabled: true,
- extractEXIF: true,
- onOpen: null,
- onClose: null,
- };
-
- // Lightbox DOM elements (created on first init)
- let lightboxModal = null;
- let lightboxImage = null;
- let lightboxCaption = null;
- let lightboxSidebar = null;
-
- /**
- * Initialize lightbox for a photo collection
- * @param {string} containerSelector - CSS selector for photo container
- * @param {Array} photos - Array of photo objects: {src, alt, caption, location}
- * @param {Object} opts - Options: {swipeEnabled, keyboardEnabled, extractEXIF, onOpen, onClose}
- * @returns {Object} - {openLightbox, closeLightbox}
- */
- function initLightbox(containerSelector, photos, opts = {}) {
- photosData = photos;
- options = { ...options, ...opts };
-
- // Merge options with defaults
- const container = document.querySelector(containerSelector);
- if (!container) {
- console.error('Photo container not found:', containerSelector);
- return { openLightbox: () => {}, closeLightbox: () => {} };
- }
-
- // Attach click listeners to all figures in the container
- const figures = container.querySelectorAll('figure[data-photo-index]');
- figures.forEach((figure) => {
- figure.addEventListener('click', () => {
- const index = parseInt(figure.dataset.photoIndex, 10);
- openLightbox(index);
- });
- });
-
- // Return public API
- return {
- openLightbox,
- closeLightbox,
- };
- }
-
- /**
- * Open lightbox to a specific photo
- * @param {number} photoIndex - 0-based index into photosData array
- */
- function openLightbox(photoIndex) {
- if (photoIndex < 0 || photoIndex >= photosData.length) {
- console.warn('Invalid photo index:', photoIndex);
- return;
- }
-
- currentPhotoIndex = photoIndex;
- isOpen = true;
-
- // Create lightbox DOM if it doesn't exist
- if (!lightboxModal) {
- createLightboxDOM();
- }
-
- // Populate lightbox with current photo
- updateLightboxContent();
-
- // Show modal
- lightboxModal.classList.add('visible');
- document.body.style.overflow = 'hidden';
-
- // Extract EXIF if enabled
- if (options.extractEXIF) {
- extractEXIFForCurrentPhoto();
- }
-
- // Attach event listeners
- attachEventListeners();
-
- // Callback
- if (options.onOpen) {
- options.onOpen(photoIndex);
- }
- }
-
- /**
- * Close lightbox
- */
- function closeLightbox() {
- if (!isOpen || !lightboxModal) return;
-
- isOpen = false;
- lightboxModal.classList.remove('visible');
- document.body.style.overflow = '';
-
- // Detach event listeners
- detachEventListeners();
-
- // Callback
- if (options.onClose) {
- options.onClose();
- }
- }
-
- /**
- * Create lightbox HTML structure and inject into DOM
- */
- function createLightboxDOM() {
- lightboxModal = document.createElement('div');
- lightboxModal.className = 'photo-lightbox';
-
- lightboxModal.innerHTML = `
- <div class="photo-lightbox-backdrop"></div>
- <div class="photo-lightbox-container">
- <button class="photo-lightbox-close" aria-label="Close lightbox">×</button>
-
- <button class="photo-lightbox-prev" aria-label="Previous photo">←</button>
-
- <div class="photo-lightbox-content">
- <img class="photo-lightbox-image" src="" alt="">
- <div class="photo-lightbox-caption"></div>
- </div>
-
- <button class="photo-lightbox-next" aria-label="Next photo">→</button>
-
- <div class="photo-lightbox-sidebar">
- <div class="photo-lightbox-sidebar-content"></div>
- </div>
- </div>
- `;
-
- document.body.appendChild(lightboxModal);
-
- // Cache element references
- lightboxImage = lightboxModal.querySelector('.photo-lightbox-image');
- lightboxCaption = lightboxModal.querySelector('.photo-lightbox-caption');
- lightboxSidebar = lightboxModal.querySelector('.photo-lightbox-sidebar-content');
-
- // Attach close button and backdrop click
- lightboxModal.querySelector('.photo-lightbox-close').addEventListener('click', closeLightbox);
- lightboxModal.querySelector('.photo-lightbox-backdrop').addEventListener('click', closeLightbox);
-
- // Attach navigation buttons
- lightboxModal.querySelector('.photo-lightbox-prev').addEventListener('click', () => navigate(-1));
- lightboxModal.querySelector('.photo-lightbox-next').addEventListener('click', () => navigate(1));
- }
-
- /**
- * Update lightbox content with current photo
- */
- function updateLightboxContent() {
- const photo = photosData[currentPhotoIndex];
- if (!photo) return;
-
- lightboxImage.src = photo.src;
- lightboxImage.alt = photo.alt || '';
-
- // Caption
- if (photo.caption) {
- lightboxCaption.textContent = photo.caption;
- lightboxCaption.style.display = 'block';
- } else {
- lightboxCaption.style.display = 'none';
- }
-
- // Update prev/next button states
- const prevBtn = lightboxModal.querySelector('.photo-lightbox-prev');
- const nextBtn = lightboxModal.querySelector('.photo-lightbox-next');
- prevBtn.disabled = currentPhotoIndex === 0;
- nextBtn.disabled = currentPhotoIndex === photosData.length - 1;
- }
-
- /**
- * Navigate to previous or next photo
- * @param {number} direction - -1 for prev, 1 for next
- */
- function navigate(direction) {
- const newIndex = currentPhotoIndex + direction;
- if (newIndex >= 0 && newIndex < photosData.length) {
- currentPhotoIndex = newIndex;
- updateLightboxContent();
-
- // Reset EXIF data and re-extract if enabled
- exifData = {};
- if (options.extractEXIF) {
- extractEXIFForCurrentPhoto();
- }
- }
- }
-
- /**
- * Extract EXIF data from current photo image element
- */
- function extractEXIFForCurrentPhoto() {
- extractEXIF(lightboxImage).then((data) => {
- exifData = data;
- updateSidebarContent();
- });
- }
-
- /**
- * Update sidebar with EXIF and location data
- */
- function updateSidebarContent() {
- const photo = photosData[currentPhotoIndex];
- let sidebarHTML = '';
-
- // Always show location if provided
- if (photo.location) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">Location</div>
- <div class="photo-metadata-value">${photo.location}</div>
- </div>
- `;
- }
-
- // Show EXIF fields if available
- if (Object.keys(exifData).length > 0) {
- sidebarHTML += `
- <div class="photo-metadata-divider"></div>
- <div class="photo-metadata-label">Camera</div>
- `;
-
- if (exifData.camera) {
- sidebarHTML += `
- <div class="photo-metadata-value">${exifData.camera}</div>
- `;
- }
- if (exifData.lens) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">Lens</div>
- <div class="photo-metadata-value">${exifData.lens}</div>
- </div>
- `;
- }
- if (exifData.focalLength) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">Focal Length</div>
- <div class="photo-metadata-value">${exifData.focalLength}</div>
- </div>
- `;
- }
- if (exifData.aperture) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">Aperture</div>
- <div class="photo-metadata-value">${exifData.aperture}</div>
- </div>
- `;
- }
- if (exifData.shutterSpeed) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">Shutter Speed</div>
- <div class="photo-metadata-value">${exifData.shutterSpeed}</div>
- </div>
- `;
- }
- if (exifData.iso) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">ISO</div>
- <div class="photo-metadata-value">${exifData.iso}</div>
- </div>
- `;
- }
- if (exifData.dateTaken) {
- sidebarHTML += `
- <div class="photo-metadata-field">
- <div class="photo-metadata-label">Date Taken</div>
- <div class="photo-metadata-value">${exifData.dateTaken}</div>
- </div>
- `;
- }
- }
-
- // Show or hide sidebar based on whether there's content
- const sidebarContainer = lightboxModal.querySelector('.photo-lightbox-sidebar');
- if (sidebarHTML) {
- lightboxSidebar.innerHTML = sidebarHTML;
- sidebarContainer.classList.add('visible');
- } else {
- sidebarContainer.classList.remove('visible');
- }
- }
-
- /**
- * Extract EXIF data from an image element using exif-js library
- * @param {HTMLImageElement} imageElement - Image to read EXIF from
- * @returns {Promise} - Resolves to {camera, lens, iso, aperture, shutterSpeed, focalLength, dateTaken}
- */
- function extractEXIF(imageElement) {
- return new Promise((resolve) => {
- // If exif-js is not loaded, return empty object
- if (typeof EXIF === 'undefined') {
- console.warn('exif-js library not loaded');
- resolve({});
- return;
- }
-
- EXIF.getData(imageElement, function() {
- const data = {
- camera: EXIF.getTag(this, 'Model'),
- lens: EXIF.getTag(this, 'LensModel'),
- iso: EXIF.getTag(this, 'ISOSpeedRatings'),
- aperture: formatAperture(EXIF.getTag(this, 'FNumber')),
- shutterSpeed: formatShutterSpeed(EXIF.getTag(this, 'ExposureTime')),
- focalLength: formatFocalLength(EXIF.getTag(this, 'FocalLength')),
- dateTaken: EXIF.getTag(this, 'DateTime'),
- };
-
- // Filter out undefined values
- Object.keys(data).forEach(key => {
- if (data[key] === undefined) {
- delete data[key];
- }
- });
-
- resolve(data);
- });
- });
- }
-
- /**
- * Format aperture value (f-number)
- */
- function formatAperture(value) {
- if (!value) return null;
- if (typeof value === 'object' && value.numerator !== undefined) {
- return `f/${(value.numerator / value.denominator).toFixed(1)}`;
- }
- return `f/${value}`;
- }
-
- /**
- * Format shutter speed (exposure time)
- */
- function formatShutterSpeed(value) {
- if (!value) return null;
- if (typeof value === 'object' && value.numerator !== undefined) {
- const speed = value.numerator / value.denominator;
- if (speed >= 1) {
- return `${speed.toFixed(1)}s`;
- }
- return `1/${Math.round(1 / speed)}`;
- }
- return value;
- }
-
- /**
- * Format focal length
- */
- function formatFocalLength(value) {
- if (!value) return null;
- if (typeof value === 'object' && value.numerator !== undefined) {
- return `${(value.numerator / value.denominator).toFixed(0)}mm`;
- }
- return `${value}mm`;
- }
-
- /**
- * Attach event listeners (swipe, keyboard, etc.)
- */
- function attachEventListeners() {
- if (options.swipeEnabled) {
- attachSwipeListeners();
- }
- if (options.keyboardEnabled) {
- attachKeyboardListeners();
- }
- }
-
- /**
- * Detach event listeners to prevent memory leaks
- */
- function detachEventListeners() {
- if (options.swipeEnabled) {
- detachSwipeListeners();
- }
- if (options.keyboardEnabled) {
- detachKeyboardListeners();
- }
- }
-
- /**
- * Touch swipe event listeners
- */
- let swipeStartX = 0;
- let swipeStartY = 0;
-
- function onTouchStart(e) {
- swipeStartX = e.touches[0].clientX;
- swipeStartY = e.touches[0].clientY;
- }
-
- function onTouchEnd(e) {
- const swipeEndX = e.changedTouches[0].clientX;
- const swipeEndY = e.changedTouches[0].clientY;
- const diffX = swipeEndX - swipeStartX;
- const diffY = swipeEndY - swipeStartY;
-
- // Only consider horizontal swipe if delta-x > delta-y
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
- if (diffX > 0) {
- // Swiped right: go to previous
- navigate(-1);
- } else {
- // Swiped left: go to next
- navigate(1);
- }
- }
- }
-
- function attachSwipeListeners() {
- lightboxModal.addEventListener('touchstart', onTouchStart, false);
- lightboxModal.addEventListener('touchend', onTouchEnd, false);
- }
-
- function detachSwipeListeners() {
- lightboxModal.removeEventListener('touchstart', onTouchStart, false);
- lightboxModal.removeEventListener('touchend', onTouchEnd, false);
- }
-
- /**
- * Keyboard event listeners
- */
- function onKeyDown(e) {
- if (!isOpen) return;
-
- switch (e.key) {
- case 'ArrowLeft':
- navigate(-1);
- e.preventDefault();
- break;
- case 'ArrowRight':
- navigate(1);
- e.preventDefault();
- break;
- case 'Escape':
- closeLightbox();
- e.preventDefault();
- break;
- default:
- break;
- }
- }
-
- function attachKeyboardListeners() {
- document.addEventListener('keydown', onKeyDown);
- }
-
- function detachKeyboardListeners() {
- document.removeEventListener('keydown', onKeyDown);
- }
-
- // Expose public API
- window.PhotoUtils = {
- initLightbox,
- extractEXIF,
- openLightbox,
- closeLightbox,
- };
-})();
diff --git a/assets/js/progress-bar.js b/assets/js/progress-bar.js
deleted file mode 100644
index e171f4f..0000000
--- a/assets/js/progress-bar.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * progress-bar.js
- * Reading progress indicator for articles
- */
-
-(function() {
- 'use strict';
-
- const progressBar = document.getElementById('progress-bar');
- if (!progressBar) return;
-
- window.addEventListener('scroll', () => {
- const windowHeight = document.documentElement.scrollHeight - window.innerHeight;
- const scrolled = window.scrollY;
- const progress = windowHeight > 0 ? (scrolled / windowHeight) * 100 : 0;
-
- progressBar.style.width = progress + '%';
- progressBar.setAttribute('aria-valuenow', Math.round(progress));
- }, { passive: true });
-})();
diff --git a/assets/js/reading-progress.js b/assets/js/reading-progress.js
new file mode 100644
index 0000000..ee1192f
--- /dev/null
+++ b/assets/js/reading-progress.js
@@ -0,0 +1,29 @@
+// Reading progress bar for single pages/articles
+(function() {
+ const progressBar = document.getElementById('reading-progress');
+
+ if (!progressBar) return;
+
+ function updateProgress() {
+ const windowHeight = window.innerHeight;
+ const documentHeight = document.documentElement.scrollHeight - windowHeight;
+ const scrollProgress = documentHeight > 0 ? (window.scrollY / documentHeight) * 100 : 0;
+ progressBar.style.width = scrollProgress + '%';
+ }
+
+ // Throttle the scroll event for better performance
+ let ticking = false;
+
+ window.addEventListener('scroll', function() {
+ if (!ticking) {
+ window.requestAnimationFrame(function() {
+ updateProgress();
+ ticking = false;
+ });
+ ticking = true;
+ }
+ }, false);
+
+ // Initial call
+ updateProgress();
+})();
diff --git a/assets/js/scroll-reveal.js b/assets/js/scroll-reveal.js
deleted file mode 100644
index 026d1be..0000000
--- a/assets/js/scroll-reveal.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * scroll-reveal.js
- * IntersectionObserver for revealing elements on scroll.
- * Adds 90ms stagger delay per sibling index within each reveal-group.
- */
-
-export function initScrollReveal() {
- 'use strict';
-
- const revealElements = document.querySelectorAll('.reveal');
- if (!revealElements.length) return;
-
- const observer = new IntersectionObserver((entries) => {
- entries.forEach((entry) => {
- if (!entry.isIntersecting) return;
-
- const el = entry.target;
- const parent = el.parentElement;
- const siblings = parent
- ? Array.from(parent.querySelectorAll(':scope > .reveal'))
- : [];
- const index = siblings.indexOf(el);
- const delay = index >= 0 ? index * 90 : 0;
-
- el.style.transitionDelay = delay + 'ms';
- el.classList.add('revealed');
-
- // Remove inline delay after transition so hover transitions are unaffected
- const cleanup = () => {
- el.style.transitionDelay = '';
- el.removeEventListener('transitionend', cleanup);
- };
- el.addEventListener('transitionend', cleanup);
-
- observer.unobserve(el);
- });
- }, { threshold: 0.1 });
-
- revealElements.forEach((el) => observer.observe(el));
-}
diff --git a/assets/js/search.js b/assets/js/search.js
new file mode 100644
index 0000000..8fb6262
--- /dev/null
+++ b/assets/js/search.js
@@ -0,0 +1,134 @@
+// Lazy-load search index from JSON file (language-aware)
+async function loadSearchIndex() {
+ if (window.searchIndex) {
+ return window.searchIndex;
+ }
+ try {
+ // Detect current language from URL
+ const isItalian = window.location.pathname.startsWith('/it/');
+ const indexPath = isItalian ? '/it/search-index.json' : '/search-index.json';
+
+ const response = await fetch(indexPath);
+ if (!response.ok) throw new Error('Failed to load search index');
+ window.searchIndex = await response.json();
+ return window.searchIndex;
+ } catch (error) {
+ console.error('Error loading search index:', error);
+ return [];
+ }
+}
+
+// Filter articles by query (case-insensitive, max 5 results)
+function filterArticles(query, articles) {
+ if (!query.trim()) {
+ return [];
+ }
+ const lowerQuery = query.toLowerCase();
+ return articles
+ .filter(article =>
+ article.title.toLowerCase().includes(lowerQuery) ||
+ article.summary.toLowerCase().includes(lowerQuery)
+ )
+ .slice(0, 5);
+}
+
+// Register Alpine.js components
+document.addEventListener('alpine:init', () => {
+ // Desktop search modal component
+ Alpine.data('searchOverlay', () => ({
+ isOpen: false,
+ searchQuery: '',
+ filteredArticles: [],
+ allArticles: [],
+ indexLoaded: false,
+
+ async open() {
+ this.isOpen = true;
+ await this.ensureIndexLoaded();
+ this.$nextTick(() => {
+ const input = this.$el.querySelector('#search-input-desktop');
+ if (input) input.focus();
+ });
+ },
+
+ close() {
+ this.isOpen = false;
+ this.searchQuery = '';
+ this.filteredArticles = [];
+ },
+
+ async ensureIndexLoaded() {
+ if (!this.indexLoaded) {
+ this.allArticles = await loadSearchIndex();
+ this.indexLoaded = true;
+ }
+ },
+
+ filterArticles(query) {
+ this.searchQuery = query;
+ this.filteredArticles = filterArticles(query, this.allArticles);
+ },
+
+ handleEscape(event) {
+ if (event.key === 'Escape') {
+ this.close();
+ }
+ }
+ }));
+
+ // Mobile search component (integrated into hamburger menu)
+ Alpine.data('mobileSearch', () => ({
+ searchQuery: '',
+ filteredArticles: [],
+ allArticles: [],
+ indexLoaded: false,
+
+ async ensureIndexLoaded() {
+ if (!this.indexLoaded) {
+ this.allArticles = await loadSearchIndex();
+ this.indexLoaded = true;
+ }
+ },
+
+ filterArticles(query) {
+ this.searchQuery = query;
+ this.filteredArticles = filterArticles(query, this.allArticles);
+ }
+ }));
+
+ // Refactored 404 page component
+ Alpine.data('notFoundPage', () => ({
+ showEasterEgg: false,
+ searchQuery: '',
+ filteredArticles: [],
+ allArticles: [],
+ indexLoaded: false,
+
+ async init() {
+ await this.ensureIndexLoaded();
+ },
+
+ async ensureIndexLoaded() {
+ if (!this.indexLoaded) {
+ this.allArticles = await loadSearchIndex();
+ this.indexLoaded = true;
+ }
+ },
+
+ filterArticles(query) {
+ this.searchQuery = query;
+ this.filteredArticles = filterArticles(query, this.allArticles);
+ },
+
+ toggleEasterEgg() {
+ this.showEasterEgg = !this.showEasterEgg;
+ },
+
+ goToRandomArticle() {
+ if (this.allArticles.length > 0) {
+ const randomArticle = this.allArticles[Math.floor(Math.random() * this.allArticles.length)];
+ window.location.href = randomArticle.url;
+ }
+ }
+ }));
+});
diff --git a/assets/js/share-sidebar.js b/assets/js/share-sidebar.js
deleted file mode 100644
index 81e5f6c..0000000
--- a/assets/js/share-sidebar.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * share-sidebar.js
- * Social sharing sidebar with copy-to-clipboard
- */
-
-export function initShareSidebar() {
- 'use strict';
-
- const sidebar = document.getElementById('share-sidebar');
- const copyBtn = document.getElementById('share-copy');
-
- if (!sidebar) return;
-
- // Share button handlers
- const shareBtns = sidebar.querySelectorAll('.share-btn[data-platform]');
- shareBtns.forEach((btn) => {
- btn.addEventListener('click', () => {
- const platform = btn.getAttribute('data-platform');
- const url = sidebar.getAttribute('data-url');
- const title = sidebar.getAttribute('data-title');
-
- const shareUrls = {
- whatsapp: `https://wa.me/?text=${encodeURIComponent(title + ' ' + url)}`,
- telegram: `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
- linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
- twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
- facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
- reddit: `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`,
- email: `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`,
- };
-
- if (shareUrls[platform]) {
- window.open(shareUrls[platform], '_blank');
- }
- });
- });
-
- // Copy to clipboard
- if (copyBtn) {
- copyBtn.addEventListener('click', () => {
- const url = sidebar.getAttribute('data-url');
- navigator.clipboard.writeText(url).then(() => {
- const originalText = copyBtn.innerHTML;
- copyBtn.innerHTML = '✓';
- setTimeout(() => {
- copyBtn.innerHTML = originalText;
- }, 2000);
- });
- });
- }
-}
diff --git a/assets/js/tag-cloud-spiral.js b/assets/js/tag-cloud-spiral.js
new file mode 100644
index 0000000..bed4645
--- /dev/null
+++ b/assets/js/tag-cloud-spiral.js
@@ -0,0 +1,122 @@
+document.addEventListener('DOMContentLoaded', function () {
+ var containers = document.querySelectorAll('[data-tag-cloud]');
+ if (!containers.length) return;
+
+ Array.prototype.forEach.call(containers, function (container) {
+ if (container.offsetWidth < 400) return;
+
+ var links = Array.prototype.slice.call(
+ container.querySelectorAll('.tag-cloud-link')
+ );
+ if (!links.length) return;
+
+ // Sort descending by weight (biggest first = placed near center)
+ links.sort(function (a, b) {
+ return parseFloat(b.dataset.weight) - parseFloat(a.dataset.weight);
+ });
+
+ // String hash → deterministic angle seed (0..2π)
+ function hashAngle(str) {
+ var h = 0;
+ for (var i = 0; i < str.length; i++) {
+ h = (h * 31 + str.charCodeAt(i)) & 0xffffffff;
+ }
+ return ((h >>> 0) / 0xffffffff) * 2 * Math.PI;
+ }
+
+ // AABB collision check
+ function overlaps(a, b) {
+ return !(
+ a.right < b.left ||
+ a.left > b.right ||
+ a.bottom < b.top ||
+ a.top > b.bottom
+ );
+ }
+
+ var placed = [];
+ var containerWidth = container.offsetWidth;
+ var cx = containerWidth / 2;
+
+ // Measure each tag before repositioning
+ var sizes = links.map(function (link) {
+ var rect = link.getBoundingClientRect();
+ return { w: rect.width, h: rect.height };
+ });
+
+ // Switch container to relative positioning and remove flex layout
+ container.style.position = 'relative';
+ container.style.display = 'block';
+ container.classList.remove('flex', 'flex-wrap');
+
+ var padding = -2; // px gap between tags (negative allows ~2px edge overlap)
+ var aStep = 0.2; // radians per spiral step
+ var rScale = (containerWidth * 0.013); // spiral tightness
+
+ var minTop = Infinity, maxBottom = -Infinity;
+
+ links.forEach(function (link, i) {
+ var w = sizes[i].w;
+ var h = sizes[i].h;
+ var seed = hashAngle(link.href);
+ var theta = seed;
+ var placed_rect;
+
+ // Step along spiral until no collision
+ for (var attempt = 0; attempt < 3000; attempt++) {
+ var r = rScale * theta;
+ var x = cx + r * Math.cos(theta) - w / 2;
+ var y = r * Math.sin(theta) - h / 2;
+
+ var candidate = { left: x, top: y, right: x + w, bottom: y + h };
+ var collision = false;
+
+ for (var j = 0; j < placed.length; j++) {
+ var p = placed[j];
+ var padded = {
+ left: p.left - padding,
+ top: p.top - padding,
+ right: p.right + padding,
+ bottom: p.bottom + padding
+ };
+ if (overlaps(candidate, padded)) {
+ collision = true;
+ break;
+ }
+ }
+
+ if (!collision) {
+ placed_rect = candidate;
+ break;
+ }
+ theta += aStep;
+ }
+
+ if (!placed_rect) {
+ // Fallback: just append to flow if spiral exhausted
+ link.style.position = 'static';
+ return;
+ }
+
+ placed.push(placed_rect);
+
+ link.style.position = 'absolute';
+ link.style.left = Math.round(placed_rect.left) + 'px';
+ link.style.top = Math.round(placed_rect.top) + 'px';
+
+ if (placed_rect.top < minTop) minTop = placed_rect.top;
+ if (placed_rect.bottom > maxBottom) maxBottom = placed_rect.bottom;
+ });
+
+ // Normalize: shift all tags so topmost is at y=16px
+ var offset = 16 - minTop;
+ links.forEach(function (link) {
+ if (link.style.position === 'absolute') {
+ link.style.top = (parseInt(link.style.top) + offset) + 'px';
+ }
+ });
+
+ // Set container height to fit all tags + 2rem bottom padding (32px)
+ container.style.height = (maxBottom - minTop + 48) + 'px';
+ });
+});
diff --git a/assets/js/theme-toggle.js b/assets/js/theme-toggle.js
index e03fce7..bb95b2a 100644
--- a/assets/js/theme-toggle.js
+++ b/assets/js/theme-toggle.js
@@ -1,87 +1,53 @@
-// theme-toggle.js
-(function() {
- const STORAGE_KEY = 'danix-theme';
- const DARK_CLASS = 'theme-dark';
- const LIGHT_CLASS = 'theme-light';
-
- // Initialize theme on page load
- function init() {
- const saved = localStorage.getItem(STORAGE_KEY);
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
- const isDark = saved === null ? prefersDark : saved === 'dark';
-
- applyTheme(isDark ? 'dark' : 'light');
+document.addEventListener('DOMContentLoaded', function() {
+ const themeToggle = document.getElementById('theme-toggle');
+ const sunIcon = document.getElementById('theme-icon-sun');
+ const moonIcon = document.getElementById('theme-icon-moon');
+
+ function updateThemeIcon() {
+ const isDark = document.documentElement.classList.contains('theme-dark');
+ if (sunIcon && moonIcon) {
+ if (isDark) {
+ sunIcon.style.display = 'block';
+ moonIcon.style.display = 'none';
+ } else {
+ sunIcon.style.display = 'none';
+ moonIcon.style.display = 'block';
+ }
+ }
}
- // Apply theme to document
- function applyTheme(theme) {
- const html = document.documentElement;
-
- html.classList.remove(DARK_CLASS, LIGHT_CLASS);
-
- if (theme === 'dark') {
- html.classList.remove(LIGHT_CLASS);
- localStorage.setItem(STORAGE_KEY, 'dark');
- } else {
- html.classList.add(LIGHT_CLASS);
- localStorage.setItem(STORAGE_KEY, 'light');
- }
+ // Update icon on initial load
+ if (sunIcon && moonIcon) {
+ updateThemeIcon();
}
- // Get current theme
- function getCurrentTheme() {
- return document.documentElement.classList.contains(LIGHT_CLASS) ? 'light' : 'dark';
+ if (!themeToggle) {
+ return;
}
- // Toggle theme
- function toggleTheme() {
- const current = getCurrentTheme();
- const next = current === 'dark' ? 'light' : 'dark';
- applyTheme(next);
+ themeToggle.addEventListener('click', function(e) {
+ e.preventDefault();
- // Dispatch custom event for other scripts to listen
- window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme: next } }));
- }
+ // Get current theme from html element
+ const htmlElement = document.documentElement;
+ const isDark = htmlElement.classList.contains('theme-dark');
+ const newTheme = isDark ? 'light' : 'dark';
- // Setup toggle button
- function setupToggleButton() {
- const btn = document.getElementById('theme-switch');
- if (btn) {
- btn.addEventListener('click', toggleTheme);
- updateToggleButtonUI();
+ // Remove both theme classes
+ htmlElement.classList.remove('theme-light', 'theme-dark');
- // Listen for theme changes to update button UI
- window.addEventListener('theme-changed', updateToggleButtonUI);
- }
- }
+ // Add the new theme class
+ htmlElement.classList.add(`theme-${newTheme}`);
- function updateToggleButtonUI() {
- const btn = document.getElementById('theme-switch');
- if (btn) {
- const current = getCurrentTheme();
- if (current === 'light') {
- btn.classList.add('light');
- } else {
- btn.classList.remove('light');
- }
- }
- }
+ // Persist to localStorage
+ localStorage.setItem('theme', newTheme);
- // Initialize on DOMContentLoaded
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', function() {
- init();
- setupToggleButton();
- });
- } else {
- init();
- setupToggleButton();
- }
+ // Update icon display
+ updateThemeIcon();
- // Expose to global scope for testing
- window.ThemeToggle = {
- toggle: toggleTheme,
- set: applyTheme,
- get: getCurrentTheme,
- };
-})();
+ // Update Feather Icons if available
+ if (window.feather) {
+ window.feather.replace();
+ }
+ });
+});
diff --git a/assets/js/typing.js b/assets/js/typing.js
deleted file mode 100644
index 369fed7..0000000
--- a/assets/js/typing.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * typing.js
- * Typing animation for .hero-role / #typed element
- */
-
-export function initTyping() {
- 'use strict';
-
- const typedElement = document.getElementById('typed');
- if (!typedElement) return;
-
- const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- const phrasesJson = typedElement.getAttribute('data-phrases');
- let phrases = [];
-
- if (phrasesJson) {
- try {
- phrases = JSON.parse(phrasesJson);
- } catch (e) {
- console.warn('Failed to parse typing phrases:', e);
- phrases = ['Security & Web Dev', 'WordPress Developer'];
- }
- }
-
- if (!phrases.length) return;
-
- let currentPhraseIndex = 0;
- let currentCharIndex = 0;
- let isDeleting = false;
-
- function type() {
- const phrase = phrases[currentPhraseIndex];
- const speed = isDeleting ? 50 : 100;
-
- if (isDeleting) {
- currentCharIndex--;
- } else {
- currentCharIndex++;
- }
-
- typedElement.textContent = phrase.substring(0, currentCharIndex);
-
- // Add cursor
- if (!isDeleting && currentCharIndex === phrase.length) {
- typedElement.innerHTML += '<span class="cursor"></span>';
- } else if (isDeleting && currentCharIndex === 0) {
- currentPhraseIndex = (currentPhraseIndex + 1) % phrases.length;
- isDeleting = false;
- setTimeout(type, 500);
- return;
- } else {
- typedElement.innerHTML = phrase.substring(0, currentCharIndex) + '<span class="cursor"></span>';
- }
-
- if (!isDeleting && currentCharIndex === phrase.length) {
- isDeleting = true;
- setTimeout(type, 2000);
- } else {
- setTimeout(type, prefersReducedMotion ? 0 : speed);
- }
- }
-
- if (prefersReducedMotion) {
- // Just show the first phrase without animation
- typedElement.textContent = phrases[0];
- } else {
- type();
- }
-}