Files
lit.ruv.wtf/website/main.js

615 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
// The base rotation is what was used for the loading animation.
// It's stored in the CSS custom property '--post-rotation'.
const baseRotation = postElement.style.getPropertyValue('--post-rotation');
postElement.addEventListener('mouseenter', function () {
postElement.style.zIndex = 100;
// Apply specific transition for this hover effect
postElement.style.transition = "transform 0.22s cubic-bezier(.5,1.5,.5,1), box-shadow 0.22s cubic-bezier(.5,1.5,.5,1)";
// Hover transform: new random rotation + scale
postElement.style.transform = `rotate(${getRandomRotation()}deg) scale(1.1)`;
});
postElement.addEventListener('mouseleave', function () {
// Apply specific transition for unhover
postElement.style.transition = "transform 0.18s cubic-bezier(.5,0,.5,1), box-shadow 0.18s cubic-bezier(.5,0,.5,1)";
// Return to base rotation + scale(1)
postElement.style.transform = `rotate(${baseRotation || '0deg'}) scale(1)`;
postElement.style.zIndex = 1;
});
}
function createPost(t) {
const post = document.createElement("div");
post.className = "post";
// Set initial transform. This will be used by addPostToLayout to set --post-rotation.
post.style.transform = `rotate(${getRandomRotation()}deg)`;
post.style.backgroundColor = getPostItColorByDate(t.createdAt);
// Hover listeners are now attached in addPostToLayout after the loading animation.
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>
</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);
console.log(`[Main] Created post with ${t.embed.length} embeds`);
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;
function createColumns(num) {
const postsContainer = document.getElementById("posts");
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; // This is the .post div
// Store the current rotation (from postDiv.style.transform) for the animation
const currentTransform = postDiv.style.transform;
const rotationMatch = currentTransform.match(/rotate\(([-\d.]+deg)\)/);
const rotation = rotationMatch ? rotationMatch[1] : '0deg';
postDiv.style.setProperty('--post-rotation', rotation); // Used by slideUpFadeIn animation
postDiv.style.zIndex = zIndex;
// Add loading animation class
postDiv.classList.add('loading');
col.appendChild(postEl);
// Remove animation class after animation completes AND THEN attach hover listeners
setTimeout(() => {
postDiv.classList.remove('loading');
// Now that the loading animation is done, attach hover listeners
attachHoverListeners(postDiv);
}, 600); // Duration of slideUpFadeIn animation (0.6s)
// Add tape only for this specific post
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;
}
function layoutPosts(postElements) {
if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
console.log('[Main] Skipping layoutPosts: video is fullscreen');
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);
});
console.log('[Main] Posts laid out, adding tape for all images');
setTimeout(() => {
if (window.addTapeToImages) {
window.addTapeToImages();
} else {
console.error('[Main] addTapeToImages function not available');
}
}, 200);
}
async function fetchPosts() {
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,
url: `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split("/").pop()}`,
embed: embeds
};
});
console.log(`[Main] Fetched ${posts.length} posts, ${posts.filter(p => p.embed.some(e => e.type === "image")).length} have images, ${posts.filter(p => p.embed.some(e => e.type === "video")).length} have videos`);
// Initialize layout columns
const numCols = getNumColumns();
layoutColumns = createColumns(numCols);
// 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);
}
}
window.addEventListener("resize", () => {
if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
console.log('[Main] Skipping resize layout: video is fullscreen');
return;
}
if (postElementsCache && layoutColumns) {
// Re-layout all existing posts
layoutColumns = createColumns(getNumColumns());
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);
}
});
fetchPosts();