mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
Redesign: Replace site with interactive terminal interface
- New xterm.js-based terminal with retro CRT aesthetics - Matrix chat integration with real-time messaging - Bluesky social media integration - Interactive commands (help, about, chat, bluesky, privacy, etc.) - Responsive design with mobile support - Privacy policy command - Quick navigation links (Docs, GitHub, Bluesky) - Preserved all image assets (blocks, items, particles, icons) - Updated README and package.json with new features
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
(function() {
|
||||
function updatePostRotations() {
|
||||
const posts = document.querySelectorAll('.post');
|
||||
|
||||
posts.forEach(post => {
|
||||
const height = post.offsetHeight;
|
||||
|
||||
// Calculate rotation: 3deg for 200px posts, 0.5deg for 500px+ posts
|
||||
const minRotation = 0.5;
|
||||
const maxRotation = 2.5;
|
||||
const minHeight = 200;
|
||||
const maxHeight = 600;
|
||||
|
||||
// Linear interpolation between min and max rotation based on height
|
||||
let rotation = maxRotation - ((height - minHeight) / (maxHeight - minHeight)) * (maxRotation - minRotation);
|
||||
|
||||
// Clamp between min and max
|
||||
rotation = Math.max(minRotation, Math.min(maxRotation, rotation));
|
||||
|
||||
// Apply alternating direction
|
||||
const isOdd = Array.from(posts).indexOf(post) % 2 === 0;
|
||||
const finalRotation = isOdd ? -rotation : rotation;
|
||||
|
||||
// Set CSS custom property
|
||||
post.style.setProperty('--dynamic-rotation', `${finalRotation}deg`);
|
||||
post.style.transform = `rotate(var(--dynamic-rotation, ${finalRotation}deg))`;
|
||||
});
|
||||
}
|
||||
|
||||
// Update rotations when DOM is ready
|
||||
function init() {
|
||||
// Wait for images to load to get accurate heights
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(updatePostRotations, 100);
|
||||
});
|
||||
} else {
|
||||
setTimeout(updatePostRotations, 100);
|
||||
}
|
||||
|
||||
// Update after window load for images
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(updatePostRotations, 200);
|
||||
});
|
||||
|
||||
// Update on resize with debouncing
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(updatePostRotations, 300);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
59
website/index.html
Normal file
59
website/index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lit.ruv.wtf Terminal</title>
|
||||
<meta name="description" content="Interactive terminal interface for Lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.">
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/180.png">
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://lit.ruv.wtf/">
|
||||
<meta property="og:title" content="Lit.ruv.wtf Terminal">
|
||||
<meta property="og:description" content="Interactive terminal interface with chat, docs, and retro vibes">
|
||||
<meta property="og:image" content="https://lit.ruv.wtf/banner.webp">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://lit.ruv.wtf/">
|
||||
<meta name="twitter:title" content="Lit.ruv.wtf Terminal">
|
||||
<meta name="twitter:description" content="Interactive terminal interface with chat, docs, and retro vibes">
|
||||
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="terminal-header">
|
||||
<div class="header-left">
|
||||
<span class="terminal-title">LIT.RUV.WTF TERMINAL</span>
|
||||
<span class="header-links">
|
||||
<a href="/docs/" target="_blank" class="header-link">Docs</a>
|
||||
<a href="https://github.com/litruv" target="_blank" class="header-link">GitHub</a>
|
||||
<a href="https://bsky.app/profile/lit.mates.dev" target="_blank" class="header-link">Bluesky</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="system-time" id="systemTime"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
<div class="status-bar">
|
||||
<span class="status-left">Ready</span>
|
||||
<span class="status-right">Type 'help' for available commands</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
|
||||
<script src="terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
674
website/main.js
674
website/main.js
@@ -1,674 +0,0 @@
|
||||
function formatDate(t) {
|
||||
return new Date(t).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function getRandomRotation() {
|
||||
return 3 * Math.random() - 1.5;
|
||||
}
|
||||
|
||||
const postItColors = [
|
||||
'#E6F3FF', // faded blue
|
||||
'#FFF9C4', // yellow
|
||||
'#E8F5E8', // green
|
||||
'#FFE4B5', // orange
|
||||
'#FFE1E6' // pink
|
||||
];
|
||||
|
||||
function getRandomPostItColor() {
|
||||
return postItColors[Math.floor(Math.random() * postItColors.length)];
|
||||
}
|
||||
|
||||
function getPostItColorByDate(dateString) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < dateString.length; i++) {
|
||||
hash = ((hash << 5) - hash) + dateString.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
const colorIndex = Math.abs(hash) % postItColors.length;
|
||||
return postItColors[colorIndex];
|
||||
}
|
||||
|
||||
let lastMediaVolume = 1;
|
||||
|
||||
// Define attachHoverListeners function
|
||||
function attachHoverListeners(postElement) {
|
||||
const baseRotation = postElement.style.getPropertyValue('--post-rotation');
|
||||
|
||||
postElement.addEventListener('mouseenter', function () {
|
||||
postElement.style.zIndex = 100;
|
||||
postElement.style.transition = "transform 0.22s cubic-bezier(.5,1.5,.5,1), box-shadow 0.22s cubic-bezier(.5,1.5,.5,1)";
|
||||
postElement.style.transform = `rotate(${getRandomRotation()}deg) scale(1.1)`;
|
||||
});
|
||||
|
||||
postElement.addEventListener('mouseleave', function () {
|
||||
postElement.style.transition = "transform 0.18s cubic-bezier(.5,0,.5,1), box-shadow 0.18s cubic-bezier(.5,0,.5,1)";
|
||||
postElement.style.transform = `rotate(${baseRotation || '0deg'}) scale(1)`;
|
||||
postElement.style.zIndex = 1;
|
||||
});
|
||||
}
|
||||
|
||||
function createPost(t) {
|
||||
const post = document.createElement("div");
|
||||
post.className = "post";
|
||||
post.style.transform = `rotate(${getRandomRotation()}deg)`;
|
||||
post.style.backgroundColor = getPostItColorByDate(t.createdAt);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = t.url;
|
||||
link.target = "_blank";
|
||||
link.style.textDecoration = "none";
|
||||
|
||||
let embedHtml = "";
|
||||
if (t.embed && t.embed.length > 0) {
|
||||
embedHtml = t.embed.map((embed, idx) => {
|
||||
if (embed.type === "image") {
|
||||
return `
|
||||
<a href="${embed.url}" target="_blank">
|
||||
<img class="image-placeholder" src="${embed.url}" alt="${embed.alt || "Image"}" data-fullres="${embed.fullres || embed.url}" />
|
||||
</a>
|
||||
`;
|
||||
} else if (embed.type === "video") {
|
||||
const isM3u8 = embed.playlist && embed.playlist.endsWith('.m3u8');
|
||||
return `
|
||||
<div class="video-embed-container">
|
||||
<video
|
||||
class="video-embed"
|
||||
${isM3u8 ? `data-hls-src="${embed.playlist}"` : `src="${embed.playlist}"`}
|
||||
poster="${embed.thumbnail || ""}"
|
||||
controls
|
||||
preload="metadata"
|
||||
tabindex="0"
|
||||
>
|
||||
Sorry, your browser doesn't support embedded videos.
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function linkifyText(text, highlightColor) {
|
||||
text = text.replace(/(^|[\s.,;:!?])#([a-zA-Z0-9_]{2,50})\b/g, (m, pre, tag) =>
|
||||
`${pre}<span class="hashtag" style="--hashtag-bg:${highlightColor};">#${tag}</span>`
|
||||
);
|
||||
text = text.replace(/(^|[\s.,;:!?])@([a-zA-Z0-9_.-]+\.[a-zA-Z0-9_.-]+)/g, (m, pre, handle) =>
|
||||
`${pre}<span class="mention"><b>@${handle}</b></span>`
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
function getHighlighterColor(cardColor) {
|
||||
if (cardColor === '#E6F3FF') return '#ffb7ce';
|
||||
if (cardColor === '#FFF9C4') return '#b7e0fd';
|
||||
if (cardColor === '#E8F5E8') return '#fff89a';
|
||||
if (cardColor === '#FFE4B5') return '#b6fcb6';
|
||||
if (cardColor === '#FFE1E6') return '#ffd59e';
|
||||
return '#fff89a';
|
||||
}
|
||||
|
||||
const cardColor = getPostItColorByDate(t.createdAt);
|
||||
const highlightColor = getHighlighterColor(cardColor);
|
||||
|
||||
const textHtml = linkifyText(t.text, highlightColor).replace(/\n/g, "<br>");
|
||||
|
||||
post.innerHTML = `
|
||||
<div class="post-header">
|
||||
<div class="avatar">
|
||||
<img src="${t.avatar}" alt="${t.author}" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="post-author">${t.author} (@${t.handle})</div>
|
||||
<div class="post-date">
|
||||
${formatDate(t.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-content">${textHtml}</div>
|
||||
${embedHtml}
|
||||
<div class="post-actions">
|
||||
<button class="action-btn like">♥ ${t.likes}</button>
|
||||
<button class="action-btn repost">⟲ ${t.reposts}</button>
|
||||
<button class="action-btn comment">↳ ${t.comments}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
const imgs = post.querySelectorAll('img');
|
||||
imgs.forEach(img => {
|
||||
img.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
createShadowbox(post, img);
|
||||
});
|
||||
});
|
||||
|
||||
post.querySelectorAll('.video-embed-container').forEach(container => {
|
||||
const video = container.querySelector('video');
|
||||
if (!video) return;
|
||||
|
||||
video.volume = lastMediaVolume;
|
||||
|
||||
video.addEventListener('mousedown', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
video.addEventListener('volumechange', function () {
|
||||
if (!isNaN(video.volume)) {
|
||||
lastMediaVolume = video.volume;
|
||||
document.querySelectorAll('video.video-embed').forEach(v => {
|
||||
if (v !== video && Math.abs(v.volume - lastMediaVolume) > 0.01) {
|
||||
v.volume = lastMediaVolume;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('play', function () {
|
||||
document.querySelectorAll('video.video-embed').forEach(v => {
|
||||
if (v !== video && !v.paused) {
|
||||
v.pause();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.25
|
||||
});
|
||||
observer.observe(video);
|
||||
});
|
||||
|
||||
const hlsVideos = post.querySelectorAll('video[data-hls-src]');
|
||||
if (hlsVideos.length > 0 && window.Hls) {
|
||||
hlsVideos.forEach(video => {
|
||||
const src = video.getAttribute('data-hls-src');
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src;
|
||||
} else if (window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
} else {
|
||||
video.outerHTML = '<div style="color:red;padding:1em;text-align:center;">Video format not supported</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
|
||||
link.href = t.url;
|
||||
link.appendChild(post);
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
function createShadowbox(postEl, imgEl) {
|
||||
const existing = document.getElementById('shadowbox-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'shadowbox-modal';
|
||||
modal.className = 'shadowbox-modal';
|
||||
|
||||
const postClone = postEl.cloneNode(true);
|
||||
postClone.className += ' shadowbox-post';
|
||||
postClone.style.background = postEl.style.backgroundColor || '#fff';
|
||||
|
||||
const tapeElements = postClone.querySelectorAll('.tape');
|
||||
tapeElements.forEach(tape => tape.remove());
|
||||
|
||||
const imgs = postClone.querySelectorAll('img');
|
||||
const originalImgs = postEl.querySelectorAll('img');
|
||||
let focusedImg = null;
|
||||
let clickedImageIndex = -1;
|
||||
|
||||
originalImgs.forEach((originalImg, idx) => {
|
||||
if (originalImg === imgEl) {
|
||||
clickedImageIndex = idx;
|
||||
}
|
||||
});
|
||||
|
||||
imgs.forEach((cloneImg, idx) => {
|
||||
if (cloneImg.classList.contains('image-placeholder')) {
|
||||
const fullSizeUrl = getFullSizeImageUrl(cloneImg.src);
|
||||
cloneImg.src = fullSizeUrl;
|
||||
cloneImg.className += ' shadowbox-image';
|
||||
}
|
||||
if (idx === clickedImageIndex && cloneImg.classList.contains('image-placeholder')) {
|
||||
focusedImg = cloneImg;
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.className = 'shadowbox-close';
|
||||
closeBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
closeShadowbox();
|
||||
});
|
||||
postClone.appendChild(closeBtn);
|
||||
|
||||
postClone.addEventListener('click', e => e.stopPropagation());
|
||||
modal.appendChild(postClone);
|
||||
modal.addEventListener('click', closeShadowbox);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
setTimeout(() => {
|
||||
modal.classList.add('visible');
|
||||
postClone.classList.add('visible');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
if (focusedImg) {
|
||||
focusedImg.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 400);
|
||||
|
||||
function closeShadowbox() {
|
||||
modal.classList.remove('visible');
|
||||
postClone.classList.remove('visible');
|
||||
setTimeout(() => {
|
||||
modal.remove();
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function escListener(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeShadowbox();
|
||||
window.removeEventListener('keydown', escListener);
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', escListener);
|
||||
|
||||
let lastFocusedIdx = Array.from(imgs).findIndex(cloneImg => cloneImg === focusedImg);
|
||||
let snapTimeout = null;
|
||||
let isInitialScroll = true;
|
||||
|
||||
setTimeout(() => {
|
||||
isInitialScroll = false;
|
||||
}, 800);
|
||||
|
||||
postClone.addEventListener('scroll', () => {
|
||||
if (isInitialScroll) return;
|
||||
|
||||
let modalRect = postClone.getBoundingClientRect();
|
||||
let modalCenter = modalRect.top + modalRect.height / 2;
|
||||
|
||||
let candidates = [];
|
||||
imgs.forEach((img, idx) => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
const center = rect.top + rect.height / 2;
|
||||
const dist = Math.abs(center - modalCenter);
|
||||
candidates.push({ idx, dist, center, rect });
|
||||
});
|
||||
|
||||
candidates.sort((a, b) => a.dist - b.dist);
|
||||
|
||||
let best = candidates[0];
|
||||
if (
|
||||
best.idx !== lastFocusedIdx &&
|
||||
best.dist < best.rect.height * 0.75
|
||||
) {
|
||||
lastFocusedIdx = best.idx;
|
||||
if (snapTimeout) clearTimeout(snapTimeout);
|
||||
snapTimeout = setTimeout(() => {
|
||||
imgs[lastFocusedIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 60);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getFullSizeImageUrl(thumbUrl) {
|
||||
if (!thumbUrl) return thumbUrl;
|
||||
return thumbUrl.replace(/@jpeg$/, '').replace(/\?.*$/, '');
|
||||
}
|
||||
|
||||
function filterOriginalPosts(feed) {
|
||||
return feed.filter(item => {
|
||||
const isRepost = item?.reason?.$type === "app.bsky.feed.defs#reasonRepost";
|
||||
const isReply = item?.post?.record?.reply;
|
||||
return !isRepost && !isReply;
|
||||
});
|
||||
}
|
||||
|
||||
let postElementsCache = null;
|
||||
let layoutColumns = null;
|
||||
let postQueue = [];
|
||||
let isProcessingQueue = false;
|
||||
let currentNumColumns = 0; // Variable to store the current number of columns
|
||||
|
||||
function createColumns(num) {
|
||||
const postsContainer = document.getElementById("posts");
|
||||
if (!postsContainer) return [];
|
||||
postsContainer.innerHTML = "";
|
||||
const columns = [];
|
||||
for (let i = 0; i < num; i++) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "masonry-col";
|
||||
postsContainer.appendChild(col);
|
||||
columns.push(col);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
function getNumColumns() {
|
||||
if (window.innerWidth >= 1200) return 3;
|
||||
if (window.innerWidth >= 800) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function getShortestColumn(columns) {
|
||||
let minHeight = Infinity;
|
||||
let shortestColumns = [];
|
||||
|
||||
// Find all columns with the minimum height
|
||||
for (const col of columns) {
|
||||
const height = col.offsetHeight;
|
||||
if (height < minHeight) {
|
||||
minHeight = height;
|
||||
shortestColumns = [col];
|
||||
} else if (height === minHeight) {
|
||||
shortestColumns.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple columns have the same height, prefer the middle one
|
||||
if (shortestColumns.length > 1) {
|
||||
const middleIndex = Math.floor(columns.length / 2);
|
||||
const middleColumn = columns[middleIndex];
|
||||
if (shortestColumns.includes(middleColumn)) {
|
||||
return middleColumn;
|
||||
}
|
||||
// If middle column isn't shortest, return the one closest to middle
|
||||
let closestToMiddle = shortestColumns[0];
|
||||
let minDistance = Math.abs(columns.indexOf(closestToMiddle) - middleIndex);
|
||||
|
||||
for (const col of shortestColumns) {
|
||||
const distance = Math.abs(columns.indexOf(col) - middleIndex);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestToMiddle = col;
|
||||
}
|
||||
}
|
||||
return closestToMiddle;
|
||||
}
|
||||
|
||||
return shortestColumns[0];
|
||||
}
|
||||
|
||||
function addPostToLayout(postEl, zIndex) {
|
||||
if (!layoutColumns) return;
|
||||
|
||||
const col = getShortestColumn(layoutColumns);
|
||||
const postDiv = postEl.firstElementChild;
|
||||
|
||||
const currentTransform = postDiv.style.transform;
|
||||
const rotationMatch = currentTransform.match(/rotate\(([-\d.]+deg)\)/);
|
||||
const rotation = rotationMatch ? rotationMatch[1] : '0deg';
|
||||
|
||||
postDiv.style.setProperty('--post-rotation', rotation);
|
||||
postDiv.style.zIndex = zIndex;
|
||||
|
||||
postDiv.classList.add('loading');
|
||||
|
||||
col.appendChild(postEl);
|
||||
|
||||
setTimeout(() => {
|
||||
postDiv.classList.remove('loading');
|
||||
attachHoverListeners(postDiv);
|
||||
}, 600);
|
||||
|
||||
setTimeout(() => {
|
||||
const imgs = postEl.querySelectorAll('img.image-placeholder');
|
||||
if (imgs.length > 0 && window.addTapeToPost) {
|
||||
window.addTapeToPost(postEl);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function processPostQueue() {
|
||||
if (isProcessingQueue || postQueue.length === 0) return;
|
||||
|
||||
isProcessingQueue = true;
|
||||
|
||||
while (postQueue.length > 0) {
|
||||
const { postEl, zIndex } = postQueue.shift();
|
||||
|
||||
// Create promises for this post's media
|
||||
const promises = [];
|
||||
|
||||
// Preload images
|
||||
const imgs = postEl.querySelectorAll("img");
|
||||
if (imgs.length) {
|
||||
promises.push(...Array.from(imgs).map(img => {
|
||||
if (img.complete) return Promise.resolve();
|
||||
return new Promise(res => {
|
||||
img.onload = img.onerror = res;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Preload video metadata
|
||||
const videos = postEl.querySelectorAll("video");
|
||||
if (videos.length) {
|
||||
promises.push(...Array.from(videos).map(video => {
|
||||
if (video.readyState >= 1) return Promise.resolve();
|
||||
return new Promise(res => {
|
||||
video.onloadedmetadata = video.onerror = res;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for this post's media to load before adding to layout
|
||||
if (promises.length > 0) {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// Add post to layout
|
||||
addPostToLayout(postEl, zIndex);
|
||||
|
||||
// Small delay between posts for visual effect
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
isProcessingQueue = false;
|
||||
|
||||
// Replace loading indicator with "view more" button when all posts are processed
|
||||
hideLoadingIndicator();
|
||||
showViewMoreButton();
|
||||
}
|
||||
|
||||
function layoutPosts(postElements) {
|
||||
if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
|
||||
return;
|
||||
}
|
||||
const numCols = getNumColumns();
|
||||
const columns = createColumns(numCols);
|
||||
postElements.forEach((postEl, index) => {
|
||||
const col = getShortestColumn(columns);
|
||||
postEl.firstElementChild.style.zIndex = postElements.length - index;
|
||||
col.appendChild(postEl);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.addTapeToImages) {
|
||||
window.addTapeToImages();
|
||||
} else {
|
||||
console.error('[Main] addTapeToImages function not available');
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function showLoadingIndicator() {
|
||||
const postsContainer = document.getElementById("posts");
|
||||
if (!postsContainer) return;
|
||||
|
||||
const loadingDiv = document.createElement("div");
|
||||
loadingDiv.id = "loading-posts";
|
||||
loadingDiv.className = "loading-posts";
|
||||
loadingDiv.innerHTML = `
|
||||
<div class="loading-posts-content">
|
||||
<div class="loading-spinner"></div>
|
||||
Loading posts...
|
||||
</div>
|
||||
`;
|
||||
postsContainer.appendChild(loadingDiv);
|
||||
}
|
||||
|
||||
function hideLoadingIndicator() {
|
||||
const loadingDiv = document.getElementById("loading-posts");
|
||||
if (loadingDiv) {
|
||||
loadingDiv.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showViewMoreButton() {
|
||||
const postsContainer = document.getElementById("posts");
|
||||
|
||||
// Check if button already exists
|
||||
if (document.getElementById("view-more-button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewMoreDiv = document.createElement("a");
|
||||
viewMoreDiv.id = "view-more-button";
|
||||
viewMoreDiv.href = "https://bsky.app/profile/lit.mates.dev";
|
||||
viewMoreDiv.target = "_blank";
|
||||
viewMoreDiv.className = "view-more-posts";
|
||||
viewMoreDiv.innerHTML = `
|
||||
<div class="view-more-posts-content">
|
||||
<strong>View more on Bluesky →</strong><br>
|
||||
<small>Follow @lit.mates.dev for more posts</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert after the posts container but before footer
|
||||
postsContainer.insertAdjacentElement('afterend', viewMoreDiv);
|
||||
}
|
||||
|
||||
async function fetchPosts() {
|
||||
// Show loading indicator
|
||||
showLoadingIndicator();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=lit.mates.dev&limit=20&filter=posts_no_replies"
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch posts");
|
||||
const data = await res.json();
|
||||
|
||||
const posts = filterOriginalPosts(data.feed).map(item => {
|
||||
let embeds = [];
|
||||
const embedObj = item.post.embed;
|
||||
if (embedObj) {
|
||||
if (embedObj.$type === "app.bsky.embed.images#view" && Array.isArray(embedObj.images)) {
|
||||
embeds = embedObj.images.map(img => ({
|
||||
type: "image",
|
||||
url: img.thumb,
|
||||
alt: img.alt || "",
|
||||
fullres: img.fullsize || img.full || img.thumb
|
||||
}));
|
||||
} else if (embedObj.$type === "app.bsky.embed.video#view") {
|
||||
embeds = [{
|
||||
type: "video",
|
||||
playlist: embedObj.playlist,
|
||||
thumbnail: embedObj.thumbnail,
|
||||
alt: embedObj.alt || "",
|
||||
aspectRatio: embedObj.aspectRatio || null
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
author: item.post.author.displayName,
|
||||
handle: item.post.author.handle,
|
||||
avatar: item.post.author.avatar,
|
||||
createdAt: item.post.record.createdAt,
|
||||
text: item.post.record.text,
|
||||
likes: item.post.likeCount || 0,
|
||||
reposts: item.post.repostCount || 0,
|
||||
comments: item.post.replyCount || 0,
|
||||
url: `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split("/").pop()}`,
|
||||
embed: embeds
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize layout columns
|
||||
const numCols = getNumColumns();
|
||||
layoutColumns = createColumns(numCols);
|
||||
currentNumColumns = numCols; // Initialize currentNumColumns
|
||||
|
||||
// Create all post elements
|
||||
postElementsCache = posts.map(post => createPost(post));
|
||||
|
||||
// Add posts to queue in order
|
||||
postQueue = postElementsCache.map((postEl, index) => ({
|
||||
postEl,
|
||||
zIndex: postElementsCache.length - index
|
||||
}));
|
||||
|
||||
// Start processing the queue
|
||||
processPostQueue();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error fetching posts:", err);
|
||||
// Hide loading indicator on error
|
||||
hideLoadingIndicator();
|
||||
|
||||
// Show error message
|
||||
const postsContainer = document.getElementById("posts");
|
||||
if (postsContainer) {
|
||||
const errorDiv = document.createElement("div");
|
||||
errorDiv.className = "loading-posts";
|
||||
errorDiv.innerHTML = `
|
||||
<div class="loading-posts-content" style="color: #dc2626;">
|
||||
⚠ Failed to load posts. Please refresh the page.
|
||||
</div>
|
||||
`;
|
||||
postsContainer.appendChild(errorDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newNumCols = getNumColumns();
|
||||
|
||||
if (postElementsCache && layoutColumns && newNumCols !== currentNumColumns) {
|
||||
currentNumColumns = newNumCols; // Update the current number of columns
|
||||
// Re-layout all existing posts
|
||||
layoutColumns = createColumns(newNumCols);
|
||||
postElementsCache.forEach((postEl, index) => {
|
||||
if (postEl.parentNode) { // Only re-layout posts that are already added
|
||||
const zIndex = postElementsCache.length - index;
|
||||
const col = getShortestColumn(layoutColumns);
|
||||
const postDiv = postEl.firstElementChild;
|
||||
postDiv.style.zIndex = zIndex;
|
||||
col.appendChild(postEl);
|
||||
}
|
||||
});
|
||||
// Re-add tape after layout changes using the full refresh
|
||||
setTimeout(() => {
|
||||
if (window.refreshTape) {
|
||||
window.refreshTape();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
if (document.getElementById("posts")) {
|
||||
fetchPosts();
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
(function() {
|
||||
let currentLayout = null;
|
||||
let posts = [];
|
||||
let columns = [];
|
||||
let originalPositions = new Map();
|
||||
|
||||
function initMasonry() {
|
||||
const postsContainer = document.getElementById('posts');
|
||||
if (!postsContainer) return;
|
||||
|
||||
posts = Array.from(postsContainer.querySelectorAll('.post'));
|
||||
columns = Array.from(postsContainer.querySelectorAll('.masonry-col'));
|
||||
|
||||
if (posts.length === 0 || columns.length === 0) return;
|
||||
|
||||
// Assign z-index based on post order (newer posts first)
|
||||
posts.forEach((post, index) => {
|
||||
post.style.zIndex = 100 - index;
|
||||
});
|
||||
|
||||
// Store original positions of posts
|
||||
posts.forEach((post, index) => {
|
||||
const rect = post.getBoundingClientRect();
|
||||
originalPositions.set(post, {
|
||||
originalParent: post.parentNode,
|
||||
originalIndex: index,
|
||||
originalRect: rect
|
||||
});
|
||||
});
|
||||
|
||||
checkLayout();
|
||||
}
|
||||
|
||||
function checkLayout() {
|
||||
const isMobile = window.innerWidth <= 799;
|
||||
const newLayout = isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
if (newLayout !== currentLayout) {
|
||||
currentLayout = newLayout;
|
||||
// Don't redistribute - just let CSS media queries handle it
|
||||
// The layout change is handled purely by CSS
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initMasonry);
|
||||
} else {
|
||||
initMasonry();
|
||||
}
|
||||
|
||||
// Listen for resize events but don't do anything that affects layout
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(checkLayout, 150);
|
||||
});
|
||||
})();
|
||||
2402
website/mining.js
2402
website/mining.js
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Number Match</title>
|
||||
<link rel="stylesheet" href="numbermatchstyle.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<section class="app__game" aria-label="Number Match board and controls">
|
||||
<header class="app__header">
|
||||
<h1 class="app__title">Number Match</h1>
|
||||
<p class="app__subtitle">Clear the board by pairing numbers that match or add to ten.</p>
|
||||
</header>
|
||||
<div class="controls" role="group" aria-label="Game controls">
|
||||
<button id="newGameButton" type="button" class="control-button control-button--secondary">New Game</button>
|
||||
</div>
|
||||
<section class="status" aria-live="polite">
|
||||
<p id="message" class="status__message" role="status"></p>
|
||||
<div class="status__metrics">
|
||||
<span class="status__metric">
|
||||
Matches cleared: <strong id="matchesCount">0</strong>
|
||||
</span>
|
||||
<span class="status__metric">
|
||||
Tiles remaining: <strong id="tilesRemaining">0</strong>
|
||||
</span>
|
||||
<span class="status__metric">
|
||||
Add Numbers used: <strong id="addNumbersUsed">0/3</strong>
|
||||
</span>
|
||||
<span class="status__metric">
|
||||
Hints used: <strong id="hintsUsed">0</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="board" id="board" aria-label="Number grid"></section>
|
||||
<div class="board-actions">
|
||||
<button id="addNumbersButton" type="button" class="board-action board-action--primary" aria-label="Add numbers">
|
||||
<span aria-hidden="true" class="board-action__icon">+</span>
|
||||
<span id="addNumbersCounter" class="board-action__badge" aria-hidden="true">3</span>
|
||||
<span class="sr-only">Add numbers</span>
|
||||
</button>
|
||||
<button id="hintButton" type="button" class="board-action board-action--secondary" aria-label="Hint">
|
||||
<span aria-hidden="true">?</span>
|
||||
<span class="sr-only">Hint</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="app__info" aria-labelledby="info-heading">
|
||||
<h2 id="info-heading">How To Play</h2>
|
||||
<p>Select two tiles that are either identical (for example, 8 and 8) or sum to ten (like 3 and 7). Clear every number to win.</p>
|
||||
<h3>Match Rules</h3>
|
||||
<ul>
|
||||
<li>Paths can run horizontally, vertically, diagonally, or wrap in reading order.</li>
|
||||
<li>Every cell between selections must be empty along the chosen path.</li>
|
||||
<li>Wrap-around allows the end of a row to connect to the start of the next when the path is clear.</li>
|
||||
</ul>
|
||||
<h3>Strategy & Tips</h3>
|
||||
<ul>
|
||||
<li>Scan rows and columns first for obvious pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.</li>
|
||||
<li>Use diagonal and wrap-around matches to reach blocked numbers.</li>
|
||||
<li><em>Add Numbers</em> unlocks fresh tiles in up to three batches—save it for when the board runs dry.</li>
|
||||
</ul>
|
||||
<h3>Frequently Asked Questions</h3>
|
||||
<p><strong>Which numbers appear?</strong> Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.</p>
|
||||
<p><strong>What counts as wrap-around?</strong> Reading order connections across a row break, provided every intervening cell is empty.</p>
|
||||
</aside>
|
||||
</main>
|
||||
<script src="numbermatch.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,958 +0,0 @@
|
||||
/**
|
||||
* Shuffle an array in place using the Fisher-Yates algorithm.
|
||||
* @template T
|
||||
* @param {T[]} array Array to shuffle.
|
||||
* @returns {T[]} The shuffled array.
|
||||
*/
|
||||
function shuffle(array) {
|
||||
for (let i = array.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const temp = array[i];
|
||||
array[i] = array[j];
|
||||
array[j] = temp;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core game state manager for Number Match logic.
|
||||
*/
|
||||
class NumberMatchGame {
|
||||
/**
|
||||
* @param {{ width?: number, rows?: number }} [options] Configuration object.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
const { width = 9, rows = 6 } = options;
|
||||
this.width = width;
|
||||
this.initialRows = rows;
|
||||
const initialState = NumberMatchGame.generateInitialState(width, rows);
|
||||
this.tiles = initialState.boardTiles;
|
||||
this.reserveTiles = initialState.reserveTiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the board with a fresh puzzle.
|
||||
* @returns {void}
|
||||
*/
|
||||
reset() {
|
||||
const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows);
|
||||
this.tiles = initialState.boardTiles;
|
||||
this.reserveTiles = initialState.reserveTiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shallow copy of the current tiles.
|
||||
* @returns {(number|null)[]} Current tile values.
|
||||
*/
|
||||
getTiles() {
|
||||
return [...this.tiles];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the remaining non-null tiles.
|
||||
* @returns {number} Number of tiles left on the board.
|
||||
*/
|
||||
getRemainingCount() {
|
||||
const boardCount = this.tiles.filter((tile) => tile !== null).length;
|
||||
return boardCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the board has been cleared.
|
||||
* @returns {boolean} True when all tiles have been removed.
|
||||
*/
|
||||
isComplete() {
|
||||
return this.tiles.every((tile) => tile === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to match and remove two indices.
|
||||
* @param {number} firstIndex Index of the first tile.
|
||||
* @param {number} secondIndex Index of the second tile.
|
||||
* @returns {boolean} True if the match succeeded.
|
||||
*/
|
||||
selectPair(firstIndex, secondIndex) {
|
||||
if (!this.canPair(firstIndex, secondIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.tiles[firstIndex] = null;
|
||||
this.tiles[secondIndex] = null;
|
||||
this.compactEmptyRows();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the two indices can form a valid pair.
|
||||
* @param {number} firstIndex Index of the first tile.
|
||||
* @param {number} secondIndex Index of the second tile.
|
||||
* @returns {boolean} True if the tiles satisfy match rules and path constraints.
|
||||
*/
|
||||
canPair(firstIndex, secondIndex) {
|
||||
if (firstIndex === secondIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const valueA = this.tiles[firstIndex];
|
||||
const valueB = this.tiles[secondIndex];
|
||||
|
||||
if (valueA === null || valueB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(valueA === valueB || valueA + valueB === 10)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.hasClearPath(firstIndex, secondIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append remaining numbers to the end of the grid, respecting Add Numbers behavior.
|
||||
* @returns {boolean} True when new numbers were appended.
|
||||
*/
|
||||
addNumbers(usesRemaining = 1) {
|
||||
this.compactEmptyRows();
|
||||
return this.appendRemainingTiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect active tiles in reading order.
|
||||
* @returns {number[]} Non-null tile values.
|
||||
*/
|
||||
getActiveTiles() {
|
||||
return this.tiles.filter((tile) => tile !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append remaining tiles in reading order after exhausting reserve uses.
|
||||
* @returns {boolean} True when tiles were appended.
|
||||
*/
|
||||
appendRemainingTiles() {
|
||||
const remaining = this.getActiveTiles();
|
||||
if (remaining.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.trimTrailingEmptySlots();
|
||||
this.tiles.push(...remaining);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trailing null placeholders from the board.
|
||||
* @returns {void}
|
||||
*/
|
||||
trimTrailingEmptySlots() {
|
||||
while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) {
|
||||
this.tiles.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a straight path between two indices is unobstructed.
|
||||
* @param {number} firstIndex One tile index.
|
||||
* @param {number} secondIndex The other tile index.
|
||||
* @returns {boolean} True if any allowed path between the indices is clear.
|
||||
*/
|
||||
hasClearPath(firstIndex, secondIndex) {
|
||||
const start = Math.min(firstIndex, secondIndex);
|
||||
const end = Math.max(firstIndex, secondIndex);
|
||||
|
||||
const rowStart = Math.floor(start / this.width);
|
||||
const rowEnd = Math.floor(end / this.width);
|
||||
const colStart = start % this.width;
|
||||
const colEnd = end % this.width;
|
||||
|
||||
if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const diff = end - start;
|
||||
if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rowDelta = rowEnd - rowStart;
|
||||
const colDelta = colEnd - colStart;
|
||||
|
||||
if (rowDelta === colDelta && rowDelta > 0) {
|
||||
if (colStart + rowDelta < this.width && this.isSegmentClear(start, end, this.width + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rowDelta === -colDelta && rowDelta > 0) {
|
||||
if (colStart - rowDelta >= 0 && this.isSegmentClear(start, end, this.width - 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return this.isSegmentClear(start, end, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any fully empty rows from the board to keep the grid compact.
|
||||
* @returns {void}
|
||||
*/
|
||||
compactEmptyRows() {
|
||||
let index = 0;
|
||||
while (index < this.tiles.length) {
|
||||
const remaining = this.tiles.length - index;
|
||||
const span = Math.min(this.width, remaining);
|
||||
const slice = this.tiles.slice(index, index + span);
|
||||
const isEmptyRow = slice.every((value) => value === null);
|
||||
if (isEmptyRow) {
|
||||
this.tiles.splice(index, span);
|
||||
continue;
|
||||
}
|
||||
index += this.width;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the indices along a stepped path are empty.
|
||||
* @param {number} start Starting index (inclusive).
|
||||
* @param {number} end Ending index (inclusive).
|
||||
* @param {number} step Increment applied each iteration.
|
||||
* @returns {boolean} True if no non-null tiles exist between start and end.
|
||||
*/
|
||||
isSegmentClear(start, end, step) {
|
||||
for (let current = start + step; current < end; current += step) {
|
||||
if (this.tiles[current] !== null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the provided index is valid for the current tile array.
|
||||
* @param {number} index Index to evaluate.
|
||||
* @param {(number|null)[]} tiles Tile array.
|
||||
* @returns {boolean} True when the index is within bounds.
|
||||
*/
|
||||
static isValidIndex(index, tiles) {
|
||||
return index >= 0 && index < tiles.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a randomized board configuration split between visible tiles and a reserve pile.
|
||||
* @param {number} width Number of columns.
|
||||
* @param {number} rows Number of initial rows.
|
||||
* @returns {{ boardTiles: number[], reserveTiles: number[] }} Generated tile sets.
|
||||
*/
|
||||
static generateInitialState(width, rows) {
|
||||
const boardCapacity = width * rows;
|
||||
const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2));
|
||||
const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches);
|
||||
const forcedAssignments = new Map();
|
||||
|
||||
const horizontalStarts = [];
|
||||
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < width - 1; colIndex += 1) {
|
||||
horizontalStarts.push(rowIndex * width + colIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const shuffledStarts = shuffle(horizontalStarts);
|
||||
const reserved = new Set();
|
||||
let startCursor = 0;
|
||||
|
||||
forcedPairs.forEach((pair) => {
|
||||
let startIndex = null;
|
||||
while (startCursor < shuffledStarts.length) {
|
||||
const candidate = shuffledStarts[startCursor];
|
||||
startCursor += 1;
|
||||
if (reserved.has(candidate) || reserved.has(candidate + 1)) {
|
||||
continue;
|
||||
}
|
||||
startIndex = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
if (startIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
forcedAssignments.set(startIndex, pair[0]);
|
||||
forcedAssignments.set(startIndex + 1, pair[1]);
|
||||
reserved.add(startIndex);
|
||||
reserved.add(startIndex + 1);
|
||||
});
|
||||
|
||||
const boardTiles = new Array(boardCapacity).fill(null);
|
||||
|
||||
for (let index = 0; index < boardCapacity; index += 1) {
|
||||
const forcedValue = forcedAssignments.get(index);
|
||||
if (typeof forcedValue === "number") {
|
||||
boardTiles[index] = forcedValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawNextForced = forcedAssignments.get(index + 1);
|
||||
const nextForcedValue = typeof rawNextForced === "number" ? rawNextForced : null;
|
||||
|
||||
boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue);
|
||||
}
|
||||
|
||||
const reserveTiles = NumberMatchGame.buildReserveTiles(boardTiles, boardCapacity);
|
||||
|
||||
return {
|
||||
boardTiles,
|
||||
reserveTiles
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a random pair of values that form a legal match.
|
||||
* @returns {[number, number]} Two values that either match or sum to ten.
|
||||
*/
|
||||
static generatePair() {
|
||||
const complementPairs = [
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
[5, 5]
|
||||
];
|
||||
const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
if (Math.random() < 0.5) {
|
||||
const value = identicalValues[Math.floor(Math.random() * identicalValues.length)];
|
||||
return [value, value];
|
||||
}
|
||||
|
||||
const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)];
|
||||
return [...pair];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the predetermined matches that appear on the initial board.
|
||||
* @param {number} target Total matches to prepare.
|
||||
* @returns {Array<[number, number]>} Ordered list of forced pairs.
|
||||
*/
|
||||
static getForcedPairs(target) {
|
||||
const essentialPairs = [[5, 5]];
|
||||
const complementPool = shuffle([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
[9, 1],
|
||||
[8, 2],
|
||||
[7, 3],
|
||||
[6, 4]
|
||||
]);
|
||||
const pairs = [];
|
||||
|
||||
for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) {
|
||||
pairs.push(essentialPairs[i]);
|
||||
}
|
||||
|
||||
let complementIndex = 0;
|
||||
while (pairs.length < target && complementIndex < complementPool.length) {
|
||||
pairs.push(complementPool[complementIndex]);
|
||||
complementIndex += 1;
|
||||
}
|
||||
|
||||
while (pairs.length < target) {
|
||||
pairs.push(NumberMatchGame.generatePair());
|
||||
}
|
||||
|
||||
return shuffle(pairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a filler value that will not immediately create an accessible match.
|
||||
* @param {Array<number|null>} tiles Current partially filled board.
|
||||
* @param {number} width Board width.
|
||||
* @param {number} index Index to populate.
|
||||
* @param {number|null} nextForcedValue Upcoming forced value, if one exists.
|
||||
* @returns {number} Safe filler digit.
|
||||
*/
|
||||
static pickSafeFillerValue(tiles, width, index, nextForcedValue) {
|
||||
const attempt = (respectNext) => {
|
||||
const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null);
|
||||
const candidates = [];
|
||||
for (let value = 1; value <= 9; value += 1) {
|
||||
if (!forbidden.has(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
let candidates = attempt(true);
|
||||
if (candidates.length === 0) {
|
||||
candidates = attempt(false);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return Math.floor(Math.random() * 9) + 1;
|
||||
}
|
||||
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect forbidden values based on already placed neighbors and optional future constraints.
|
||||
* @param {Array<number|null>} tiles Current board state.
|
||||
* @param {number} width Board width.
|
||||
* @param {number} index Target index.
|
||||
* @param {number|null} nextForcedValue Forced value to appear next in sequence, if any.
|
||||
* @returns {Set<number>} Values that should not be used at the index.
|
||||
*/
|
||||
static collectForbiddenValues(tiles, width, index, nextForcedValue) {
|
||||
const forbidden = new Set();
|
||||
const register = NumberMatchGame.registerForbiddenValue;
|
||||
|
||||
if (index > 0) {
|
||||
register(forbidden, tiles[index - 1]);
|
||||
}
|
||||
|
||||
const row = Math.floor(index / width);
|
||||
const col = index % width;
|
||||
|
||||
if (row > 0) {
|
||||
register(forbidden, tiles[index - width]);
|
||||
if (col > 0) {
|
||||
register(forbidden, tiles[index - width - 1]);
|
||||
}
|
||||
if (col < width - 1) {
|
||||
register(forbidden, tiles[index - width + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof nextForcedValue === "number") {
|
||||
register(forbidden, nextForcedValue);
|
||||
}
|
||||
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a value and its complement as forbidden for immediate placement.
|
||||
* @param {Set<number>} forbidden Collection of disallowed values.
|
||||
* @param {number|null|undefined} value Value to mark as forbidden.
|
||||
* @returns {void}
|
||||
*/
|
||||
static registerForbiddenValue(forbidden, value) {
|
||||
if (typeof value !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
forbidden.add(value);
|
||||
const complement = NumberMatchGame.getComplement(value);
|
||||
forbidden.add(complement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the complement digit that forms a sum-to-ten relationship.
|
||||
* @param {number} value Value between 1 and 9.
|
||||
* @returns {number} Complement digit.
|
||||
*/
|
||||
static getComplement(value) {
|
||||
if (value === 5) {
|
||||
return 5;
|
||||
}
|
||||
const complement = 10 - value;
|
||||
return Math.min(9, Math.max(1, complement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the reserve pile so that complements exist for non-matching board values.
|
||||
* @param {Array<number|null>} boardTiles Generated board.
|
||||
* @param {number} boardCapacity Total board slots.
|
||||
* @returns {number[]} Reserve tiles array.
|
||||
*/
|
||||
static buildReserveTiles(boardTiles, boardCapacity) {
|
||||
const reserve = [];
|
||||
|
||||
boardTiles.forEach((value) => {
|
||||
if (typeof value !== "number") {
|
||||
return;
|
||||
}
|
||||
reserve.push(NumberMatchGame.getComplement(value));
|
||||
});
|
||||
|
||||
while (reserve.length < Math.ceil(boardCapacity * 1.1)) {
|
||||
const [first, second] = NumberMatchGame.generatePair();
|
||||
reserve.push(first, second);
|
||||
}
|
||||
|
||||
return shuffle(reserve);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI controller responsible for rendering and interactions.
|
||||
*/
|
||||
class NumberMatchUI {
|
||||
/**
|
||||
* @param {NumberMatchGame} game Game state instance.
|
||||
*/
|
||||
constructor(game) {
|
||||
this.game = game;
|
||||
this.boardElement = /** @type {HTMLElement} */ (document.getElementById("board"));
|
||||
this.messageElement = /** @type {HTMLElement} */ (document.getElementById("message"));
|
||||
this.matchesElement = /** @type {HTMLElement} */ (document.getElementById("matchesCount"));
|
||||
this.tilesRemainingElement = /** @type {HTMLElement} */ (document.getElementById("tilesRemaining"));
|
||||
this.addNumbersElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersUsed"));
|
||||
this.addNumbersBadgeElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersCounter"));
|
||||
this.hintsElement = /** @type {HTMLElement} */ (document.getElementById("hintsUsed"));
|
||||
this.addNumbersButton = /** @type {HTMLButtonElement} */ (document.getElementById("addNumbersButton"));
|
||||
this.hintButton = /** @type {HTMLButtonElement} */ (document.getElementById("hintButton"));
|
||||
this.newGameButton = /** @type {HTMLButtonElement} */ (document.getElementById("newGameButton"));
|
||||
this.newGameDialogBackdrop = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialogBackdrop"));
|
||||
this.newGameDialog = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialog"));
|
||||
this.newGameConfirmButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("confirmNewGameButton"));
|
||||
this.newGameCancelButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("cancelNewGameButton"));
|
||||
this.previouslyFocusedElement = null;
|
||||
|
||||
this.selectedIndices = [];
|
||||
this.matchesCleared = 0;
|
||||
this.addNumbersUsed = 0;
|
||||
this.hintsUsed = 0;
|
||||
this.highlightedIndices = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize event listeners and render the starting board.
|
||||
* @returns {void}
|
||||
*/
|
||||
init() {
|
||||
this.bindControlEvents();
|
||||
const restored = this.restoreState();
|
||||
this.renderBoard();
|
||||
this.updateMetrics();
|
||||
this.hintButton.disabled = this.game.isComplete();
|
||||
if (restored) {
|
||||
this.showMessage("Welcome back! Your last board was restored.", "success");
|
||||
} else {
|
||||
this.showMessage("Select two tiles that match or sum to ten.");
|
||||
}
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to control buttons.
|
||||
* @returns {void}
|
||||
*/
|
||||
bindControlEvents() {
|
||||
this.addNumbersButton.addEventListener("click", () => {
|
||||
const remainingUses = NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed;
|
||||
const appended = this.game.addNumbers(remainingUses);
|
||||
if (appended) {
|
||||
if (remainingUses > 0) {
|
||||
this.addNumbersUsed += 1;
|
||||
}
|
||||
this.showMessage("Numbers duplicated to the end of the grid.", "success");
|
||||
this.selectedIndices = [];
|
||||
this.clearHighlights();
|
||||
this.renderBoard();
|
||||
this.updateMetrics();
|
||||
this.persistState();
|
||||
} else {
|
||||
this.showMessage("No numbers remain to duplicate.", "error");
|
||||
this.updateAddNumbersPrompt();
|
||||
}
|
||||
});
|
||||
|
||||
this.hintButton.addEventListener("click", () => {
|
||||
const hint = this.findHintPair();
|
||||
if (!hint) {
|
||||
this.showMessage("No matches available. Try adding numbers.", "error");
|
||||
this.updateAddNumbersPrompt();
|
||||
return;
|
||||
}
|
||||
this.selectedIndices = [];
|
||||
this.highlightedIndices = hint;
|
||||
this.hintsUsed += 1;
|
||||
this.renderBoard();
|
||||
this.updateMetrics();
|
||||
this.showMessage("Try matching the highlighted tiles.");
|
||||
this.persistState();
|
||||
});
|
||||
|
||||
this.newGameButton.addEventListener("click", () => {
|
||||
this.openNewGameDialog();
|
||||
});
|
||||
|
||||
if (this.newGameDialogBackdrop && this.newGameConfirmButton && this.newGameCancelButton) {
|
||||
this.newGameConfirmButton.addEventListener("click", () => {
|
||||
this.closeNewGameDialog();
|
||||
this.startNewGame();
|
||||
});
|
||||
|
||||
this.newGameCancelButton.addEventListener("click", () => {
|
||||
this.closeNewGameDialog();
|
||||
});
|
||||
|
||||
this.newGameDialogBackdrop.addEventListener("mousedown", (event) => {
|
||||
if (event.target === this.newGameDialogBackdrop) {
|
||||
this.closeNewGameDialog();
|
||||
}
|
||||
});
|
||||
|
||||
this.newGameDialogBackdrop.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.closeNewGameDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tile clicks and manage selection/matching lifecycle.
|
||||
* @param {number} index Tile index that was clicked.
|
||||
* @returns {void}
|
||||
*/
|
||||
handleTileClick(index) {
|
||||
const tiles = this.game.getTiles();
|
||||
this.clearHighlights();
|
||||
if (tiles[index] === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedIndices.includes(index)) {
|
||||
this.selectedIndices = this.selectedIndices.filter((item) => item !== index);
|
||||
this.renderBoard();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedIndices.push(index);
|
||||
|
||||
if (this.selectedIndices.length < 2) {
|
||||
this.renderBoard();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderBoard();
|
||||
this.clearHighlights();
|
||||
const [first, second] = this.selectedIndices;
|
||||
const didMatch = this.game.selectPair(first, second);
|
||||
if (didMatch) {
|
||||
this.matchesCleared += 1;
|
||||
this.selectedIndices = [];
|
||||
this.renderBoard();
|
||||
this.updateMetrics();
|
||||
this.showMessage("Great match!", "success");
|
||||
if (this.game.isComplete()) {
|
||||
this.showMessage("Board cleared! Start a new game when you are ready.", "success");
|
||||
this.addNumbersButton.disabled = true;
|
||||
this.hintButton.disabled = true;
|
||||
this.clearHighlights();
|
||||
}
|
||||
this.persistState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedIndices = [second];
|
||||
this.renderBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the board based on the current game state.
|
||||
* @returns {void}
|
||||
*/
|
||||
renderBoard() {
|
||||
const tiles = this.game.getTiles();
|
||||
this.boardElement.innerHTML = "";
|
||||
this.boardElement.style.gridTemplateColumns = `repeat(${this.game.width}, minmax(0, 1fr))`;
|
||||
tiles.forEach((value, index) => {
|
||||
const tile = document.createElement("button");
|
||||
tile.type = "button";
|
||||
tile.className = "tile";
|
||||
tile.dataset.index = index.toString();
|
||||
if (value === null) {
|
||||
tile.classList.add("tile--empty");
|
||||
tile.disabled = true;
|
||||
tile.setAttribute("aria-hidden", "true");
|
||||
} else {
|
||||
tile.textContent = String(value);
|
||||
tile.addEventListener("click", () => this.handleTileClick(index));
|
||||
}
|
||||
if (this.selectedIndices.includes(index)) {
|
||||
tile.classList.add("tile--selected");
|
||||
}
|
||||
if (this.highlightedIndices.includes(index)) {
|
||||
tile.classList.add("tile--hinted");
|
||||
}
|
||||
this.boardElement.appendChild(tile);
|
||||
});
|
||||
this.updateAddNumbersPrompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update score-related UI metrics.
|
||||
* @returns {void}
|
||||
*/
|
||||
updateMetrics() {
|
||||
this.matchesElement.textContent = String(this.matchesCleared);
|
||||
this.tilesRemainingElement.textContent = String(this.game.getRemainingCount());
|
||||
this.addNumbersElement.textContent = `${this.addNumbersUsed}/${NumberMatchUI.ADD_NUMBERS_LIMIT}`;
|
||||
this.hintsElement.textContent = String(this.hintsUsed);
|
||||
this.updateAddNumbersBadge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect remaining Add Numbers uses in the badge.
|
||||
* @returns {void}
|
||||
*/
|
||||
updateAddNumbersBadge() {
|
||||
const remaining = Math.max(0, NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed);
|
||||
if (this.addNumbersBadgeElement) {
|
||||
this.addNumbersBadgeElement.textContent = String(remaining);
|
||||
this.addNumbersBadgeElement.classList.toggle("board-action__badge--empty", remaining === 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any highlighted tile indices.
|
||||
* @returns {void}
|
||||
*/
|
||||
clearHighlights() {
|
||||
this.highlightedIndices = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a status message with optional styling.
|
||||
* @param {string} message Message to display.
|
||||
* @param {"success"|"error"|"info"} [type="info"] Message type for styling.
|
||||
* @returns {void}
|
||||
*/
|
||||
showMessage(message, type = "info") {
|
||||
this.messageElement.textContent = message;
|
||||
this.messageElement.classList.remove("status__message--error", "status__message--success");
|
||||
if (type === "success") {
|
||||
this.messageElement.classList.add("status__message--success");
|
||||
} else if (type === "error") {
|
||||
this.messageElement.classList.add("status__message--error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state and begin a new puzzle.
|
||||
* @returns {void}
|
||||
*/
|
||||
startNewGame() {
|
||||
this.game.reset();
|
||||
this.matchesCleared = 0;
|
||||
this.addNumbersUsed = 0;
|
||||
this.hintsUsed = 0;
|
||||
this.selectedIndices = [];
|
||||
this.addNumbersButton.disabled = false;
|
||||
this.hintButton.disabled = false;
|
||||
this.clearHighlights();
|
||||
this.renderBoard();
|
||||
this.updateMetrics();
|
||||
this.showMessage("New puzzle loaded. Good luck!", "success");
|
||||
this.hintButton.disabled = false;
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the custom new game confirmation dialog.
|
||||
* @returns {void}
|
||||
*/
|
||||
openNewGameDialog() {
|
||||
if (!this.newGameDialogBackdrop || !this.newGameDialog || !this.newGameConfirmButton) {
|
||||
this.startNewGame();
|
||||
return;
|
||||
}
|
||||
|
||||
this.previouslyFocusedElement = document.activeElement;
|
||||
this.newGameDialogBackdrop.hidden = false;
|
||||
requestAnimationFrame(() => {
|
||||
this.newGameDialogBackdrop.classList.add("is-visible");
|
||||
this.newGameDialogBackdrop.setAttribute("aria-hidden", "false");
|
||||
this.newGameDialog.focus({ preventScroll: true });
|
||||
this.newGameConfirmButton.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the custom new game dialog and restore focus.
|
||||
* @returns {void}
|
||||
*/
|
||||
closeNewGameDialog() {
|
||||
if (!this.newGameDialogBackdrop) {
|
||||
return;
|
||||
}
|
||||
this.newGameDialogBackdrop.classList.remove("is-visible");
|
||||
this.newGameDialogBackdrop.setAttribute("aria-hidden", "true");
|
||||
setTimeout(() => {
|
||||
if (this.newGameDialogBackdrop) {
|
||||
this.newGameDialogBackdrop.hidden = true;
|
||||
}
|
||||
}, 180);
|
||||
|
||||
if (this.previouslyFocusedElement instanceof HTMLElement) {
|
||||
this.previouslyFocusedElement.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether any valid matches remain on the board.
|
||||
* @returns {boolean} True if at least one valid pair exists.
|
||||
*/
|
||||
hasAvailableMoves() {
|
||||
return NumberMatchUI.findFirstValidPair(this.game) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Add Numbers control state, including glow cues and disabling logic.
|
||||
* @returns {void}
|
||||
*/
|
||||
updateAddNumbersPrompt() {
|
||||
const hasNumbersRemaining = this.game.getRemainingCount() > 0;
|
||||
const activeAction = hasNumbersRemaining && !this.game.isComplete();
|
||||
this.addNumbersButton.disabled = !activeAction;
|
||||
|
||||
const noMoves = !this.hasAvailableMoves();
|
||||
const shouldGlow = noMoves && hasNumbersRemaining && activeAction;
|
||||
this.addNumbersButton.classList.toggle("board-action--attention", shouldGlow);
|
||||
this.updateAddNumbersBadge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current board and score metrics to localStorage.
|
||||
* @returns {void}
|
||||
*/
|
||||
persistState() {
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
const state = {
|
||||
version: NumberMatchUI.STORAGE_VERSION,
|
||||
width: this.game.width,
|
||||
tiles: this.game.getTiles(),
|
||||
reserveTiles: Array.isArray(this.game.reserveTiles) ? [...this.game.reserveTiles] : undefined,
|
||||
matchesCleared: this.matchesCleared,
|
||||
addNumbersUsed: this.addNumbersUsed,
|
||||
hintsUsed: this.hintsUsed,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
storage.setItem(NumberMatchUI.STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.error("[NumberMatch] Failed to persist state", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to restore board and metrics from localStorage.
|
||||
* @returns {boolean} True when a valid state was restored.
|
||||
*/
|
||||
restoreState() {
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
const raw = storage.getItem(NumberMatchUI.STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || parsed.version !== NumberMatchUI.STORAGE_VERSION) {
|
||||
return false;
|
||||
}
|
||||
if (parsed.width !== this.game.width || !Array.isArray(parsed.tiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tiles = parsed.tiles.map((value) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
});
|
||||
|
||||
if (tiles.length === 0 || tiles.every((value) => value === undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.game.tiles = tiles;
|
||||
if (Array.isArray(parsed.reserveTiles)) {
|
||||
this.game.reserveTiles = parsed.reserveTiles
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
}
|
||||
|
||||
const numericOrDefault = (value, fallback, clampMin = null, clampMax = null) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return fallback;
|
||||
}
|
||||
let result = numeric;
|
||||
if (clampMin !== null) {
|
||||
result = Math.max(clampMin, result);
|
||||
}
|
||||
if (clampMax !== null) {
|
||||
result = Math.min(clampMax, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
this.matchesCleared = numericOrDefault(parsed.matchesCleared, 0, 0, Number.MAX_SAFE_INTEGER);
|
||||
this.addNumbersUsed = numericOrDefault(parsed.addNumbersUsed, 0, 0, NumberMatchUI.ADD_NUMBERS_LIMIT);
|
||||
this.hintsUsed = numericOrDefault(parsed.hintsUsed, 0, 0, Number.MAX_SAFE_INTEGER);
|
||||
this.selectedIndices = [];
|
||||
this.highlightedIndices = [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[NumberMatch] Failed to restore state", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a valid pair of tiles for hint functionality.
|
||||
NumberMatchUI.STORAGE_KEY = "numbermatch-state-v1";
|
||||
NumberMatchUI.STORAGE_VERSION = 1;
|
||||
* @returns {[number, number] | null} Tuple of indices or null when no match exists.
|
||||
*/
|
||||
findHintPair() {
|
||||
return NumberMatchUI.findFirstValidPair(this.game);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the first valid pair of tiles available.
|
||||
* @param {NumberMatchGame} game Game state instance to inspect.
|
||||
* @returns {[number, number] | null} Indices representing a valid pair or null.
|
||||
*/
|
||||
static findFirstValidPair(game) {
|
||||
const tiles = game.getTiles();
|
||||
for (let i = 0; i < tiles.length; i += 1) {
|
||||
if (tiles[i] === null) {
|
||||
continue;
|
||||
}
|
||||
for (let j = i + 1; j < tiles.length; j += 1) {
|
||||
if (tiles[j] === null) {
|
||||
continue;
|
||||
}
|
||||
if (game.canPair(i, j)) {
|
||||
return [i, j];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
NumberMatchUI.ADD_NUMBERS_LIMIT = 3;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const game = new NumberMatchGame({ width: 9, rows: 6 });
|
||||
const ui = new NumberMatchUI(game);
|
||||
ui.init();
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
width: min(1100px, 100%);
|
||||
}
|
||||
|
||||
.app__game,
|
||||
.app__info {
|
||||
background-color: #1e293b;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.app__title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: linear-gradient(135deg, #22d3ee, #2563eb);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.control-button:hover,
|
||||
.control-button:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.control-button--secondary {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status__message {
|
||||
min-height: 1.5rem;
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status__message.status__message--error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.status__message.status__message--success {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status__metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.status__metric {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.board {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.board-actions {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.board-action {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.board-action:hover,
|
||||
.board-action:focus {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.board-action:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.board-action--primary {
|
||||
background: linear-gradient(135deg, #22d3ee, #2563eb);
|
||||
}
|
||||
|
||||
.board-action--secondary {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.board-action__icon {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.board-action__badge {
|
||||
position: absolute;
|
||||
top: -0.35rem;
|
||||
right: -0.35rem;
|
||||
min-width: 1.5rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: #0ea5e9;
|
||||
color: #0f172a;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 6px 14px rgba(14, 165, 233, 0.45);
|
||||
}
|
||||
|
||||
.board-action__badge.board-action__badge--empty {
|
||||
background: #cbd5f5;
|
||||
color: #475569;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.board-action--attention {
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.4), 0 18px 36px rgba(8, 145, 178, 0.45);
|
||||
animation: board-action-pulse 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes board-action-pulse {
|
||||
from {
|
||||
transform: translateY(-2px) scale(1);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 0.75rem;
|
||||
border: none;
|
||||
font-size: clamp(1.125rem, 4vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
background: linear-gradient(145deg, #f1f5f9, #cbd5f5);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tile:hover,
|
||||
.tile:focus {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 25px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.tile--selected {
|
||||
background: linear-gradient(145deg, #fde68a, #f97316);
|
||||
box-shadow: 0 16px 35px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.tile--hinted:not(.tile--selected) {
|
||||
background: linear-gradient(145deg, #bfdbfe, #93c5fd);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5), 0 12px 24px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.tile--empty {
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.45);
|
||||
cursor: default;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tile--empty:hover,
|
||||
.tile--empty:focus {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app__info h2,
|
||||
.app__info h3 {
|
||||
margin-top: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.app__info ul {
|
||||
padding-left: 1.25rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.app__info li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app__info p {
|
||||
color: #cbd5f5;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.app__info {
|
||||
order: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.board {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.board-actions {
|
||||
margin-top: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.board-action {
|
||||
width: 3.1rem;
|
||||
height: 3.1rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.board-action__badge {
|
||||
top: -0.25rem;
|
||||
right: -0.25rem;
|
||||
min-width: 1.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -5,32 +5,12 @@
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://lit.ruv.wtf/</loc>
|
||||
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
|
||||
<lastmod>2026-03-04T00:00:00+00:00</lastmod>
|
||||
<priority>1.00</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lit.ruv.wtf/materials/</loc>
|
||||
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lit.ruv.wtf/numbermatch/</loc>
|
||||
<lastmod>2025-11-19T00:00:00+00:00</lastmod>
|
||||
<priority>0.70</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lit.ruv.wtf/docs/sitemap.xml</loc>
|
||||
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
|
||||
<lastmod>2026-03-04T00:00:00+00:00</lastmod>
|
||||
<priority>0.80</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lit.ruv.wtf/privacy.html</loc>
|
||||
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
|
||||
<priority>0.50</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lit.ruv.wtf/mining/</loc>
|
||||
<lastmod>2025-11-24T00:00:00+00:00</lastmod>
|
||||
<priority>0.70</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -1,730 +0,0 @@
|
||||
:root {
|
||||
--main-bg: #242424;
|
||||
--main-fg: #f0f0f0;
|
||||
--accent: #2563eb;
|
||||
--like: #db2777;
|
||||
--comment: #059669;
|
||||
--github: #181717;
|
||||
--youtube: #FF0000;
|
||||
--steam: #1b2838;
|
||||
--discord: #7289da;
|
||||
--bluesky: #3E5BFF;
|
||||
--post-bg: #fff;
|
||||
--post-border: #000;
|
||||
--avatar-bg: #f0f0f0;
|
||||
--avatar-border: #000;
|
||||
--shadow-light: rgba(0, 0, 0, 0.3);
|
||||
--shadow-heavy: rgba(0, 0, 0, 1);
|
||||
--shadow-mid: rgba(0, 0, 0, 0.05);
|
||||
--header-shadow1: #ffffff52;
|
||||
--header-shadow2: #000;
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--main-bg);
|
||||
font-family: monospace;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h20v20H0V0zm2 2v16h16V2H2z' fill='%23000000' fill-opacity='0.05'/%3E%3C/svg%3E");
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bg-pattern {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
opacity: 0.05;
|
||||
z-index: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1560px; /* increased by 30% from 1200px */
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 2rem 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-top: 160px; /* increased from 100px */
|
||||
box-sizing: border-box;
|
||||
overflow: visible; /* ensure expanded content is visible */
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 80px;
|
||||
color: var(--main-fg);
|
||||
font-size: 20px;
|
||||
padding-top: 2rem; /* add extra space above header */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
h1 {
|
||||
transition: all 0.3s ease;
|
||||
text-shadow: 2px 2px 0 var(--header-shadow1), 3px 3px 0 var(--header-shadow2);
|
||||
margin-bottom: 0.5rem;
|
||||
transform-origin: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
margin-top: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
margin: 0 10px;
|
||||
color: var(--main-fg);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.social-links a:hover {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.3s ease;
|
||||
text-shadow: 0 0 3px white;
|
||||
}
|
||||
|
||||
.social-links a:hover.fa-github,
|
||||
.social-links a:hover .fab.fa-github {
|
||||
color: var(--github) !important;
|
||||
}
|
||||
|
||||
.social-links a:hover.fa-youtube,
|
||||
.social-links a:hover .fab.fa-youtube {
|
||||
color: var(--youtube) !important;
|
||||
}
|
||||
|
||||
.social-links a:hover.fa-steam,
|
||||
.social-links a:hover .fab.fa-steam {
|
||||
color: var(--steam) !important;
|
||||
}
|
||||
|
||||
.social-links a:hover.fa-discord,
|
||||
.social-links a:hover .fab.fa-discord {
|
||||
color: var(--discord) !important;
|
||||
}
|
||||
|
||||
.social-links a:hover.fa-bluesky,
|
||||
.social-links a:hover .fa-brands.fa-bluesky {
|
||||
color: var(--bluesky) !important;
|
||||
}
|
||||
|
||||
#posts {
|
||||
max-width: 1560px; /* increased by 30% from 1200px */
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-left: 80px; /* Increase left padding to balance the negative margin */
|
||||
padding-right: 40px;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
min-height: 0; /* Prevent flex container from causing jumps */
|
||||
/* Add stable height to prevent jumps */
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.masonry-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 320px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0; /* Remove gap so negative margins overlap */
|
||||
transition: none; /* Prevent transitions during layout changes */
|
||||
will-change: auto; /* Prevent unnecessary layer promotion */
|
||||
/* Prevent reflow during resize */
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
#posts {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
transition: none; /* Prevent transitions during layout changes */
|
||||
/* Remove the min-height that was causing issues */
|
||||
min-height: auto;
|
||||
margin-top: 2rem; /* Add top margin to push posts below header */
|
||||
margin-right: 0px;
|
||||
margin-left:0px;
|
||||
padding-right: 0; /* Remove right padding on mobile */
|
||||
}
|
||||
.masonry-col {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
/* Hide overflow columns on mobile */
|
||||
}
|
||||
.masonry-col:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ensure posts don't have excessive negative margins on mobile */
|
||||
.post {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.post {
|
||||
background: var(--post-bg);
|
||||
/* Add post-it note lines: slightly darker than background, full width, spaced ~32px apart */
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 27px,
|
||||
rgba(0,0,0,0.04) 27px,
|
||||
rgba(0,0,0,0.04) 29px,
|
||||
transparent 29px,
|
||||
transparent 32px
|
||||
);
|
||||
background-size: 100% 32px;
|
||||
background-repeat: repeat;
|
||||
padding: 2rem 1.5rem;
|
||||
margin-bottom: -0.2rem;
|
||||
margin-top: -0.2rem;
|
||||
margin-left: -40px;
|
||||
margin-right: -40px;
|
||||
border: 1.8px solid var(--post-border);
|
||||
position: relative; /* Add back for z-index to work */
|
||||
box-shadow: 3px 7px 4px var(--shadow-light);
|
||||
transition: transform 0.35s cubic-bezier(0.68, -0.55, 0.27, 1.55),
|
||||
box-shadow 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55),
|
||||
opacity 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
break-inside: avoid;
|
||||
z-index: 1;
|
||||
|
||||
/* Dynamic rotation based on height */
|
||||
--post-height: 300px; /* Default fallback */
|
||||
--min-rotation: 0.5deg; /* Minimum rotation for tall posts */
|
||||
--max-rotation: 3deg; /* Maximum rotation for short posts */
|
||||
--rotation-factor: clamp(var(--min-rotation), calc(var(--max-rotation) - (var(--post-height) - 200px) * 0.005), var(--max-rotation));
|
||||
|
||||
/* Apply rotation with random direction */
|
||||
transform: rotate(var(--rotation-factor));
|
||||
}
|
||||
|
||||
.post::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 55px; /* Keep existing height */
|
||||
background-color: rgba(0, 0, 0, 0.05); /* Keep existing background color */
|
||||
z-index: 0; /* Ensure it's behind the post content but above the post background lines */
|
||||
border-radius: 25%; /* Add radius to top-left corner */
|
||||
filter: blur(5px); /* Add a slight blur to soften the edges */
|
||||
}
|
||||
|
||||
/* Remove these alternate rotation rules, JS now handles rotation and color */
|
||||
/*
|
||||
.post:nth-child(odd) {
|
||||
transform: rotate(calc(-1 * var(--rotation-factor)));
|
||||
}
|
||||
.post:nth-child(even) {
|
||||
transform: rotate(var(--rotation-factor));
|
||||
}
|
||||
*/
|
||||
|
||||
.post:hover {
|
||||
/* Remove transform here, JS will handle */
|
||||
box-shadow: 6px 6px 30px var(--shadow-heavy);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin: -2rem -1.5rem 1.25rem -1.5rem; /* stretch to post edge, extra space below */
|
||||
padding: 0.75rem 1.5rem 0.75rem 1.5rem;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--avatar-bg);
|
||||
border: 2px solid var(--avatar-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
transform: rotate(-2deg);
|
||||
box-shadow: 2px 2px 0 var(--avatar-border);
|
||||
}
|
||||
|
||||
.post-content {
|
||||
text-shadow: 1px 1px 0 #fff;
|
||||
}
|
||||
|
||||
/* Ensure image containers can hold positioned tape */
|
||||
.image-placeholder,
|
||||
.image-placeholder img {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Force relative positioning on image links (containers) */
|
||||
a:has(.image-placeholder) {
|
||||
position: relative !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* JavaScript-generated tape styling */
|
||||
.tape {
|
||||
position: absolute;
|
||||
background: rgba(255, 248, 220, 0.9);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
box-shadow: 1px 1px 3px rgba(0,0,0,0.3);
|
||||
background-image: linear-gradient(45deg,
|
||||
rgba(255, 255, 255, 0.3) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
rgba(255, 255, 255, 0.3) 75%);
|
||||
background-size: 3px 3px;
|
||||
/* Zig-zag mask on top/bottom (long) sides, subtle and thin */
|
||||
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'><polygon points='0,0 100,0 100,20 0,20' fill='white'/><polyline points='0,0 5,8 10,0 15,8 20,0 25,8 30,0 35,8 40,0 45,8 50,0 55,8 60,0 65,8 70,0 75,8 80,0 85,8 90,0 95,8 100,0' fill='none' stroke='white' stroke-width='1'/><polyline points='0,20 5,12 10,20 15,12 20,20 25,12 30,20 35,12 40,20 45,12 50,20 55,12 60,20 65,12 70,20 75,12 80,20 85,12 90,20 95,12 100,20' fill='none' stroke='white' stroke-width='1'/></svg>");
|
||||
mask-image: url("data:image/svg+xml;utf8,<svg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'><polygon points='0,0 100,0 100,20 0,20' fill='white'/><polyline points='0,0 5,8 10,0 15,8 20,0 25,8 30,0 35,8 40,0 45,8 50,0 55,8 60,0 65,8 70,0 75,8 80,0 85,8 90,0 95,8 100,0' fill='none' stroke='white' stroke-width='1'/><polyline points='0,20 5,12 10,20 15,12 20,20 25,12 30,20 35,12 40,20 45,12 50,20 55,12 60,20 65,12 70,20 75,12 80,20 85,12 90,20 95,12 100,20' fill='none' stroke='white' stroke-width='1'/></svg>");
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.action-btn.like {
|
||||
color: var(--like);
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
|
||||
.action-btn.repost {
|
||||
color: var(--accent);
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
|
||||
.action-btn.comment {
|
||||
color: var(--comment);
|
||||
transform: rotate(-0.5deg);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 250px;
|
||||
margin: 20px auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post:hover {
|
||||
/* Remove transform here, JS will handle */
|
||||
box-shadow: 6px 6px 30px var(--shadow-heavy);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.subtext {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-5px) translateY(5px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(5px) translateY(-5px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(-5px) translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
h1:hover {
|
||||
animation:
|
||||
wildSpin 3s ease-in-out,
|
||||
rainbow 3s linear,
|
||||
shake 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
social-links a {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
social-links a::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
social-links a:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-links {
|
||||
margin-top: 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.project-links a {
|
||||
display: inline-block;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--post-bg);
|
||||
color: var(--main-bg);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.project-links a:hover {
|
||||
background: var(--accent);
|
||||
color: var(--post-bg);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--main-fg);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--main-fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hashtag {
|
||||
color: #222;
|
||||
/* Remove text rotation/skew, only background is "sloppy" */
|
||||
background: none;
|
||||
border-radius: 6px 12px 8px 10px / 10px 8px 12px 6px;
|
||||
padding: 0 6px 2px 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 2px 8px 0 rgba(255, 248, 154, 0.18),
|
||||
0 1px 0 0 rgba(255, 248, 154, 0.25);
|
||||
display: inline-block;
|
||||
margin: 0 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.hashtag::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
/* Default fallback color, will be overridden inline by JS */
|
||||
background: linear-gradient(
|
||||
4deg,
|
||||
var(--hashtag-bg, #fff89a) 0%,
|
||||
var(--hashtag-bg, #fff89a) 100%
|
||||
);
|
||||
opacity: 0.7;
|
||||
border-radius: 8px 14px 10px 12px / 12px 10px 14px 8px;
|
||||
transform: rotate(-2deg) skewX(-2deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mention {
|
||||
color: #388e3c;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
color: #388e3c;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
font-weight: 500; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Video center play button styling */
|
||||
.video-center-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.video-center-play::before {
|
||||
content: "▶";
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
margin-left: 4px; /* Slight offset to center the triangle visually */
|
||||
}
|
||||
|
||||
.video-center-play:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
.video-center-play:focus {
|
||||
outline: 2px solid white;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Video embed container and video styling */
|
||||
.video-embed-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-embed {
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
margin: 1.5rem 0 12px 0;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Shadowbox modal styling */
|
||||
.shadowbox-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0,0,0,0.0);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
cursor: zoom-out;
|
||||
transition: background 0.35s cubic-bezier(.5,1.5,.5,1);
|
||||
}
|
||||
|
||||
.shadowbox-modal.visible {
|
||||
background: rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
.shadowbox-post {
|
||||
transform: translateY(100vh) scale(0.98);
|
||||
max-width: 75vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
z-index: 10001;
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s cubic-bezier(.5,1.5,.5,1), transform 0.5s cubic-bezier(.5,1.5,.5,1);
|
||||
}
|
||||
|
||||
.shadowbox-post.visible {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shadowbox-image {
|
||||
max-width: 90vw !important;
|
||||
max-height: 80vh !important;
|
||||
width: auto !important;
|
||||
object-fit: contain !important;
|
||||
display: block !important;
|
||||
margin: 24px auto !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.shadowbox-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 16px;
|
||||
font-size: 2rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
z-index: 10002;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.shadowbox-close:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* Post header avatar image styling */
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Post metadata styling */
|
||||
.post-author {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Image placeholder styling */
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
border: 4px solid var(--post-border);
|
||||
background: var(--avatar-bg);
|
||||
text-align: center;
|
||||
transform: rotate(0deg);
|
||||
box-shadow: 4px 4px 0 var(--post-border);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Post animation for loading */
|
||||
@keyframes slideUpFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) rotate(var(--post-rotation, 0deg));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(var(--post-rotation, 0deg));
|
||||
}
|
||||
}
|
||||
|
||||
.post.loading {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) rotate(var(--post-rotation, 0deg));
|
||||
animation: slideUpFadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
/* Loading indicator and view more button styles */
|
||||
.loading-posts, .view-more-posts {
|
||||
text-align: center;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
background: var(--post-bg);
|
||||
border: 2px solid var(--post-border);
|
||||
box-shadow: 3px 3px 0 var(--shadow-light);
|
||||
max-width: 400px;
|
||||
transform: rotate(-1deg);
|
||||
color: #666;
|
||||
font-family: inherit;
|
||||
position: relative;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.view-more-posts {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
clear: both;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.view-more-posts:hover {
|
||||
transform: rotate(-1deg) scale(1.05);
|
||||
box-shadow: 6px 6px 0 var(--shadow-light);
|
||||
color: var(--bluesky);
|
||||
}
|
||||
|
||||
.loading-posts::before, .view-more-posts::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
z-index: 0;
|
||||
border-radius: 25%;
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
.loading-posts-content, .view-more-posts-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
222
website/styles.css
Normal file
222
website/styles.css
Normal file
@@ -0,0 +1,222 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background: #000;
|
||||
color: #0f0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 95vw;
|
||||
max-width: 1400px;
|
||||
height: 90vh;
|
||||
background: #001800;
|
||||
border: 3px solid #0f0;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 0 20px rgba(0, 255, 0, 0.3),
|
||||
inset 0 0 50px rgba(0, 255, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #002200;
|
||||
border-bottom: 2px solid #0f0;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px #0f0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
color: #0f0;
|
||||
text-decoration: none;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #0f0;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
text-shadow: 0 0 5px #0f0;
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: #0f0;
|
||||
color: #001800;
|
||||
box-shadow: 0 0 10px #0f0;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.system-time {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.xterm-screen {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: #002200;
|
||||
border-top: 2px solid #0f0;
|
||||
padding: 6px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 0 3px #0f0;
|
||||
}
|
||||
|
||||
.status-left {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-right {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* CRT screen effect */
|
||||
.container::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
|
||||
linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
|
||||
z-index: 2;
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Subtle screen flicker */
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0.98; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.98; }
|
||||
}
|
||||
|
||||
.container {
|
||||
animation: flicker 1s infinite;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: 98vw;
|
||||
height: 95vh;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.system-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 4px 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.terminal-title {
|
||||
letter-spacing: 1px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
gap: 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #001800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #0c0;
|
||||
}
|
||||
173
website/tape.js
173
website/tape.js
@@ -1,173 +0,0 @@
|
||||
function addTapeToImages() {
|
||||
const images = document.querySelectorAll('.image-placeholder');
|
||||
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const existingTape = image.querySelectorAll('.tape');
|
||||
if (existingTape.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageParent = image.parentElement;
|
||||
if (imageParent && window.getComputedStyle(imageParent).position === 'static') {
|
||||
imageParent.style.position = 'relative';
|
||||
}
|
||||
|
||||
const tape1 = createTapeElement(index, 1);
|
||||
const tape2 = createTapeElement(index, 2);
|
||||
|
||||
const usePattern1 = index % 2 === 0;
|
||||
if (usePattern1) {
|
||||
positionTape(tape1, 'top-left');
|
||||
positionTape(tape2, 'bottom-right');
|
||||
} else {
|
||||
positionTape(tape1, 'top-right');
|
||||
positionTape(tape2, 'bottom-left');
|
||||
}
|
||||
|
||||
imageParent.appendChild(tape1);
|
||||
imageParent.appendChild(tape2);
|
||||
});
|
||||
}
|
||||
|
||||
function createTapeElement(imageIndex, tapeNumber) {
|
||||
const tape = document.createElement('div');
|
||||
tape.className = 'tape';
|
||||
|
||||
const width = 60 + Math.random() * 20;
|
||||
const height = 40 + Math.random() * 16;
|
||||
|
||||
tape.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
background: rgba(255, 248, 220, 0.93);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
box-shadow: 2px 2px 6px rgba(0,0,0,0.18);
|
||||
background-image: linear-gradient(45deg,
|
||||
rgba(255, 255, 255, 0.25) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
rgba(255, 255, 255, 0.25) 75%);
|
||||
background-size: 4px 4px;
|
||||
`;
|
||||
|
||||
return tape;
|
||||
}
|
||||
|
||||
function positionTape(tape, corner) {
|
||||
const randomOffset = () => Math.random() * 8 - 4;
|
||||
const randomRotation = (Math.random() * 20 - 10);
|
||||
const OUT_X = 25;
|
||||
const OUT_Y = 18;
|
||||
|
||||
switch (corner) {
|
||||
case 'top-left':
|
||||
tape.style.top = `${-OUT_Y + randomOffset()}px`;
|
||||
tape.style.left = `${-OUT_X + randomOffset()}px`;
|
||||
tape.style.transform = `rotate(${-45 + randomRotation}deg)`;
|
||||
break;
|
||||
case 'top-right':
|
||||
tape.style.top = `${-OUT_Y + randomOffset()}px`;
|
||||
tape.style.right = `${-OUT_X + randomOffset()}px`;
|
||||
tape.style.transform = `rotate(${45 + randomRotation}deg)`;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
tape.style.bottom = `${-OUT_Y + randomOffset()}px`;
|
||||
tape.style.left = `${-OUT_X + randomOffset()}px`;
|
||||
tape.style.transform = `rotate(${45 + randomRotation}deg)`;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
tape.style.bottom = `${-OUT_Y + randomOffset()}px`;
|
||||
tape.style.right = `${-OUT_X + randomOffset()}px`;
|
||||
tape.style.transform = `rotate(${-45 + randomRotation}deg)`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshTape() {
|
||||
const existingTape = document.querySelectorAll('.tape');
|
||||
existingTape.forEach(tape => tape.remove());
|
||||
addTapeToImages();
|
||||
}
|
||||
|
||||
function addTapeToPost(postElement) {
|
||||
const images = postElement.querySelectorAll('.image-placeholder');
|
||||
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const existingTape = image.parentElement.querySelectorAll('.tape');
|
||||
if (existingTape.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageParent = image.parentElement;
|
||||
if (imageParent && window.getComputedStyle(imageParent).position === 'static') {
|
||||
imageParent.style.position = 'relative';
|
||||
}
|
||||
|
||||
const tape1 = createTapeElement(index, 1);
|
||||
const tape2 = createTapeElement(index, 2);
|
||||
|
||||
const usePattern1 = index % 2 === 0;
|
||||
if (usePattern1) {
|
||||
positionTape(tape1, 'top-left');
|
||||
positionTape(tape2, 'bottom-right');
|
||||
} else {
|
||||
positionTape(tape1, 'top-right');
|
||||
positionTape(tape2, 'bottom-left');
|
||||
}
|
||||
|
||||
imageParent.appendChild(tape1);
|
||||
imageParent.appendChild(tape2);
|
||||
});
|
||||
}
|
||||
|
||||
function testSimpleTape() {
|
||||
const firstImage = document.querySelector('.image-placeholder');
|
||||
if (!firstImage) {
|
||||
console.error('[Tape Test] No images found');
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = firstImage.parentElement;
|
||||
parent.style.position = 'relative';
|
||||
|
||||
const testTape = document.createElement('div');
|
||||
testTape.className = 'tape';
|
||||
testTape.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
background: red;
|
||||
border: 2px solid black;
|
||||
z-index: 999;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
`;
|
||||
testTape.textContent = 'TEST';
|
||||
|
||||
parent.appendChild(testTape);
|
||||
|
||||
setTimeout(() => {
|
||||
testTape.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Export functions
|
||||
window.addTapeToImages = addTapeToImages;
|
||||
window.addTapeToPost = addTapeToPost;
|
||||
window.refreshTape = refreshTape;
|
||||
window.testSimpleTape = testSimpleTape;
|
||||
1152
website/terminal.js
Normal file
1152
website/terminal.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
(function() {
|
||||
let scrollPosition = 0;
|
||||
let isResizing = false;
|
||||
let resizeTimeout;
|
||||
|
||||
function preserveScroll() {
|
||||
if (!isResizing) {
|
||||
scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
|
||||
isResizing = true;
|
||||
|
||||
// Temporarily disable smooth scrolling during resize
|
||||
document.documentElement.style.scrollBehavior = 'auto';
|
||||
}
|
||||
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
// Force restore scroll position multiple times to combat layout changes
|
||||
const restoreScroll = () => {
|
||||
window.scrollTo(0, scrollPosition);
|
||||
};
|
||||
|
||||
restoreScroll();
|
||||
requestAnimationFrame(restoreScroll);
|
||||
setTimeout(restoreScroll, 10);
|
||||
setTimeout(restoreScroll, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
// Re-enable smooth scrolling
|
||||
document.documentElement.style.scrollBehavior = 'smooth';
|
||||
isResizing = false;
|
||||
}, 100);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Listen for resize events
|
||||
window.addEventListener('resize', preserveScroll);
|
||||
|
||||
// Listen for orientation changes
|
||||
window.addEventListener('orientationchange', () => {
|
||||
preserveScroll();
|
||||
// Additional delay for orientation changes
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, scrollPosition);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Also intercept media query changes that affect layout
|
||||
const mediaQuery = window.matchMedia('(max-width: 799px)');
|
||||
mediaQuery.addListener(preserveScroll);
|
||||
})();
|
||||
Reference in New Issue
Block a user