1
0
Fork 0

Import existing project

This commit is contained in:
Caileb 2025-05-26 12:42:36 -05:00
parent 7887817595
commit 80b0cc4939
125 changed files with 16980 additions and 0 deletions

396
develop/js/c.js Normal file
View file

@ -0,0 +1,396 @@
// Web Worker Script for hash computation
function workerFunction() {
self.onmessage = function(e) {
const { type, data } = e.data;
if (type === 'pow') {
// PoW calculation
const { challenge, salt, startNonce, endNonce, target, batchId } = data;
let count = 0;
let solution = null;
processNextNonce(startNonce);
function processNextNonce(nonce) {
const input = String(challenge) + String(salt) + nonce.toString();
const msgBuffer = new TextEncoder().encode(input);
crypto.subtle.digest('SHA-256', msgBuffer)
.then(hashBuffer => {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const result = hashArray.map(b =>
b.toString(16).padStart(2, '0')).join('');
count++;
if (result.startsWith(target)) {
solution = { nonce: nonce.toString(), found: true };
self.postMessage({
type: 'pow_result',
solution: solution,
count: count,
batchId: batchId
});
return;
}
if (count % 1000 === 0) {
self.postMessage({
type: 'progress',
count: count,
batchId: batchId
});
}
if (nonce < endNonce && !solution) {
setTimeout(() => processNextNonce(nonce + 1), 0);
} else if (!solution) {
self.postMessage({
type: 'pow_result',
solution: null,
count: count,
batchId: batchId
});
}
})
.catch(err => {
self.postMessage({
type: 'error',
error: 'Crypto API error: ' + err.message
});
});
}
} else {
// Handle other message types if needed in the future
self.postMessage({ type: 'error', error: 'Unknown message type: ' + type });
}
};
}
const workerCode = "(" + workerFunction.toString() + ")()";
// Proof-of-Space Worker script with buffer pooling
function posWorkerFunction() {
self.onmessage = async function(e) {
const { type, seedHex, isDecoy } = e.data;
if (type === 'pos') {
const minMB = 48, maxMB = 160;
let seedInt = parseInt(seedHex.slice(0, 8), 16);
if (isNaN(seedInt)) seedInt = Math.floor(Math.random() * (maxMB - minMB + 1));
const CHUNK_MB = isDecoy
? (minMB + ((seedInt * 3 + 17) % (maxMB - minMB + 1)))
: (minMB + (seedInt % (maxMB - minMB + 1)));
const CHUNK_SIZE = CHUNK_MB * 1024 * 1024;
const chunkCount = 4 + (seedInt % 5);
const chunkSize = Math.floor(CHUNK_SIZE / chunkCount);
const FILL_STEP_4K = 4096, FILL_STEP_1K = 1024;
const FILL_STEP_SWITCH = 35 * 1024 * 1024;
const runs = 3;
// Pre-allocate buffers
// Removed baseBuf as cpuBase calculation is unused
const mainBuf = new ArrayBuffer(CHUNK_SIZE);
const view = new Uint8Array(mainBuf);
const pressureBuf = new ArrayBuffer(16 * 1024 * 1024);
const pressureView = new Uint8Array(pressureBuf);
// Removed CPU baseline calculation as it's unused upstream
const hashes = [];
const times = [];
for (let r = 0; r < runs; r++) {
const prng = seededPRNG(seedHex + r.toString(16));
// generate deterministic chunk order
const order = Array.from({ length: chunkCount }, (_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = prng() % (i + 1);
[order[i], order[j]] = [order[j], order[i]];
}
// fill view
const t0 = performance.now();
for (let c = 0; c < chunkCount; c++) {
const idx = order[c];
const start = idx * chunkSize;
const end = (idx === chunkCount - 1) ? CHUNK_SIZE : start + chunkSize;
const step = (start < FILL_STEP_SWITCH) ? FILL_STEP_4K : FILL_STEP_1K;
for (let i = start; i < end; i += step) view[i] = prng() & 0xFF;
}
const hashBuf = await crypto.subtle.digest('SHA-256', view);
const t2 = performance.now();
hashes.push(Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join(''));
times.push(Math.round(t2 - t0));
// memory pressure
for (let i = 0; i < pressureView.length; i += 4096) pressureView[i] = prng() & 0xFF;
}
// Removed cpuBase from postMessage
self.postMessage({ type: 'pos_result', hashes, times });
}
};
function seededPRNG(seedHex) {
const s = [];
for (let i = 0; i < 4; i++) s[i] = parseInt(seedHex.substr(i * 8, 8), 16) >>> 0;
function rotl(x, k) { return ((x << k) | (x >>> (32 - k))) >>> 0; }
return function() {
const t = s[1] << 9;
let r = (s[0] * 5) >>> 0;
r = rotl(r, 7) * 9 >>> 0;
const tmp = s[0] ^ s[2];
s[2] ^= s[1]; s[1] ^= s[3]; s[0] ^= s[1];
s[3] ^= tmp; s[2] ^= t; s[3] = rotl(s[3], 11);
return r >>> 0;
};
}
}
const posWorkerCode = "(" + posWorkerFunction.toString() + ")()";
// Main verification script
document.addEventListener('DOMContentLoaded', function() {
setTimeout(initVerification, 650);
function initVerification() {
const dataEl = document.getElementById('verification-data');
const targetPath = dataEl.getAttribute('data-target');
const requestID = dataEl.getAttribute('data-request-id');
startVerification();
async function startVerification() {
try {
const challengeResponse = await fetch('/api/pow/challenge?id=' + encodeURIComponent(requestID), {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!challengeResponse.ok) {
throw new Error('Failed to get challenge parameters');
}
const challengeData = await challengeResponse.json();
// Extract real and decoy seeds using obfuscated keys
const realPosSeed = challengeData.d; // 'd' is pos_seed
const decoySeed = challengeData.e || (Math.random().toString(16).slice(2, 18)); // 'e' is decoy_seed
const decoyFields = challengeData.f || []; // 'f' is decoy_fields
const verifier = new Verifier(challengeData, targetPath, requestID, decoySeed, decoyFields);
verifier.start();
} catch (error) {
showError('Verification setup failed: ' + error.message);
}
}
function createWorker() {
const blob = new Blob([workerCode], { type: 'text/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function createPosWorker() {
const blob = new Blob([posWorkerCode], { type: 'text/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function showError(message) {
const container = document.querySelector('.container');
container.classList.add('error');
container.classList.remove('success');
// Let CSS pseudo-elements render the ring and X on the existing spinner
const spinnerEl = document.querySelector('.spinner');
const statusEl = document.getElementById('status');
statusEl.style.display = 'inline-block';
statusEl.textContent = ''; // Keep this behavior as original, even if odd
statusEl.classList.add('error');
statusEl.classList.remove('success');
const spinnerContainer = document.querySelector('.spinner-container');
let errorDetails = document.getElementById('error-details');
if (!errorDetails) {
errorDetails = document.createElement('div');
errorDetails.id = 'error-details';
errorDetails.className = 'error-details';
spinnerContainer.appendChild(errorDetails);
}
// Hide error details to match success state layout
errorDetails.style.display = 'none'; // Keep this behavior as original
}
function showSuccess() {
document.querySelector('.container').classList.add('success');
document.getElementById('status').textContent = 'Redirecting';
}
function Verifier(params, targetPath, requestID, decoySeed, decoyFields) {
const workers = [];
const activeBatches = {};
let powSolution = null;
let isRunning = false;
const cpuCount = navigator.hardwareConcurrency || 4;
const workerCount = Math.max(1, Math.floor(cpuCount * 0.8));
const REDIRECT_DELAY = 1488;
this.start = function() {
setTimeout(findProofOfWork, 100);
};
async function findProofOfWork() {
try {
isRunning = true;
let decodedChallenge, decodedSalt;
try {
decodedChallenge = atob(params.a);
decodedSalt = atob(params.b);
} catch (e) {
throw new Error(`Failed to decode challenge/salt: ${e.message}`);
}
const target = '0'.repeat(params.c);
for (let i = 0; i < workerCount; i++) {
const worker = createWorker();
// Pass only 'e.data' as workerId parameter was unused
worker.onmessage = e => handleWorkerMessage(e.data);
worker.onerror = error => {
// Silently handle worker errors
};
workers.push(worker);
}
const totalRange = Number.MAX_SAFE_INTEGER;
const rangePerWorker = Math.floor(totalRange / workerCount);
for (let i = 0; i < workers.length; i++) {
const startNonce = i * rangePerWorker;
const endNonce = (i === workers.length - 1) ?
totalRange : (i + 1) * rangePerWorker - 1;
const workerId = `pow-worker-${i}`;
activeBatches[workerId] = {
// Keep workerId here as it's used as key in activeBatches
workerId: i,
startNonce,
endNonce
};
workers[i].postMessage({
type: 'pow',
data: {
challenge: decodedChallenge,
salt: decodedSalt,
startNonce,
endNonce,
target,
batchId: workerId
}
});
}
} catch (error) {
terminateWorkers();
showError(error.message);
}
}
// Removed unused 'workerId' parameter
function handleWorkerMessage(data) {
if (!isRunning) return;
if (data.type === 'pow_result') {
if (activeBatches[data.batchId]) {
delete activeBatches[data.batchId];
if (data.solution && data.solution.found) {
if (!powSolution) {
powSolution = data.solution;
proofOfWorkFound(powSolution);
}
}
}
} else if (data.type === 'error') {
showError('Compatibility error: ' + data.error);
terminateWorkers();
}
}
async function proofOfWorkFound(solution) {
isRunning = false;
terminateWorkers();
try {
// PoS via Worker
const posResult = await new Promise(res => {
const w = createPosWorker();
w.onmessage = e => { if (e.data.type==='pos_result') { res(e.data); w.terminate(); }};
w.postMessage({ type:'pos', seedHex: params.d, isDecoy:false });
});
const decoyResult = await new Promise(res => {
const w = createPosWorker();
w.onmessage = e => { if (e.data.type==='pos_result') { res(e.data); w.terminate(); }};
w.postMessage({ type:'pos', seedHex: decoySeed, isDecoy:true });
});
// Submit results
await submitSolution({ requestID, g: solution.nonce,
h: posResult.hashes, i: posResult.times,
j: decoyResult.hashes, k: decoyResult.times,
l: decoyFields });
} catch (error) {
showError(error.message);
}
}
function terminateWorkers() {
workers.forEach(worker => worker.terminate());
}
async function submitSolution(solutionData) {
try {
const response = await fetch('/api/pow/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
request_id: solutionData.requestID, // Keep descriptive
g: solutionData.g, // Nonce
h: solutionData.h, // Real PoS Hashes
i: solutionData.i, // Real PoS Times
j: solutionData.j, // Decoy Hashes
k: solutionData.k, // Decoy Times
l: solutionData.l // Decoy Fields
})
});
if (!response.ok) {
let errorMsg = `Verification failed: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMsg += ` - ${errorData.error}`;
} else {
const text = await response.text();
errorMsg += ` - Response: ${text}`;
}
} catch (parseError) {
// Silent catch
}
showError(errorMsg);
return;
}
showSuccess();
setTimeout(() => {
window.location.href = targetPath;
}, REDIRECT_DELAY);
} catch (error) {
showError('Verification failed. Please refresh the page.');
}
}
}
}
});

858
develop/js/cc.js Normal file
View file

@ -0,0 +1,858 @@
/**
* Credit Card Tracker
* Tracks reward categories, spending limits, and payment history for credit cards
*/
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const cardsContainer = document.getElementById('cards-container');
const emptyState = document.getElementById('empty-state');
const searchInput = document.getElementById('search-input');
const addCardBtn = document.getElementById('add-card-btn');
const addFirstCardBtn = document.getElementById('add-first-card-btn');
const cardModal = document.getElementById('card-modal');
const closeModalBtn = document.getElementById('close-modal');
const cardForm = document.getElementById('card-form');
const modalTitle = document.getElementById('modal-title');
const cardIdInput = document.getElementById('card-id');
const addCategoryBtn = document.getElementById('add-category-btn');
const categoriesContainer = document.getElementById('categories-container');
// Payment Modal Elements
const paymentModal = document.getElementById('payment-modal');
const closePaymentModalBtn = document.getElementById('close-payment-modal');
const paymentForm = document.getElementById('payment-form');
const paymentCardId = document.getElementById('payment-card-id');
const paymentCardName = document.getElementById('payment-card-name');
const paymentCategory = document.getElementById('payment-category');
const paymentAmount = document.getElementById('payment-amount');
const paymentDate = document.getElementById('payment-date');
// Create Payment History Modal - Will be added to DOM later
const paymentHistoryModal = document.createElement('div');
paymentHistoryModal.className = 'modal';
paymentHistoryModal.id = 'payment-history-modal';
// Initialize cards from localStorage
let cards = loadCards();
// Display cards or empty state
renderCards();
// Set today's date as default for payment date
paymentDate.valueAsDate = new Date();
// Event Listeners
addCardBtn.addEventListener('click', () => openAddCardModal());
addFirstCardBtn.addEventListener('click', () => openAddCardModal());
closeModalBtn.addEventListener('click', () => closeModal(cardModal));
closePaymentModalBtn.addEventListener('click', () => closeModal(paymentModal));
cardForm.addEventListener('submit', handleCardFormSubmit);
addCategoryBtn.addEventListener('click', () => addCategoryField('', '', ''));
paymentForm.addEventListener('submit', handlePaymentFormSubmit);
searchInput.addEventListener('input', handleSearch);
// Global event delegation for dynamically added card buttons
cardsContainer.addEventListener('click', handleCardActions);
// Check for monthly resets on page load
checkMonthlyResets();
// Initialize payment history modal
initPaymentHistoryModal();
/**
* Initialize payment history modal
*/
function initPaymentHistoryModal() {
paymentHistoryModal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Payment History</h2>
<button class="close-modal" id="close-history-modal">&times;</button>
</div>
<div id="payment-history-container">
<!-- Payment history will be loaded here -->
</div>
</div>
`;
document.body.appendChild(paymentHistoryModal);
// Add event listener to close button
document.getElementById('close-history-modal').addEventListener('click', () => {
closeModal(paymentHistoryModal);
});
}
/**
* Handle card action buttons via event delegation
*/
function handleCardActions(e) {
const target = e.target;
// Find closest card element
const cardElement = target.closest('.credit-card');
if (!cardElement) return;
const cardId = cardElement.dataset.id;
const card = cards.find(c => c.id === cardId);
if (!card) return;
// Payment button
if (target.closest('.payment-btn')) {
e.stopPropagation();
openPaymentModal(card);
return;
}
// Edit button
if (target.closest('.edit-btn')) {
e.stopPropagation();
openEditCardModal(card);
return;
}
// Delete button
if (target.closest('.delete-btn')) {
e.stopPropagation();
deleteCard(cardId);
return;
}
// View payment history (clicking on a category item)
const categoryItem = target.closest('.category-item');
if (categoryItem && categoryItem.dataset.categoryName) {
const categoryName = categoryItem.dataset.categoryName;
const category = card.categories.find(c => c.name === categoryName);
if (category) {
openPaymentHistoryModal(card, category);
}
}
}
/**
* Load cards from localStorage
*/
function loadCards() {
const storedCards = localStorage.getItem('creditCards');
return storedCards ? JSON.parse(storedCards) : [];
}
/**
* Save cards to localStorage
*/
function saveCards() {
localStorage.setItem('creditCards', JSON.stringify(cards));
}
/**
* Render all cards or empty state
*/
function renderCards() {
// Clear cards container except for the empty state
Array.from(cardsContainer.children).forEach(child => {
if (!child.classList.contains('empty-state')) {
child.remove();
}
});
// Show empty state if no cards
if (cards.length === 0) {
emptyState.style.display = 'block';
return;
}
// Hide empty state and render cards
emptyState.style.display = 'none';
// Filter cards if search input has value
let filteredCards = cards;
const searchTerm = searchInput.value.toLowerCase().trim();
if (searchTerm) {
filteredCards = cards.filter(card => {
const nameMatch = card.name.toLowerCase().includes(searchTerm);
const bankMatch = card.bank.toLowerCase().includes(searchTerm);
const categoryMatch = card.categories.some(cat =>
cat.name.toLowerCase().includes(searchTerm)
);
return nameMatch || bankMatch || categoryMatch;
});
}
// Render filtered cards
filteredCards.forEach(card => {
const cardElement = createCardElement(card);
cardsContainer.insertBefore(cardElement, emptyState);
});
}
/**
* Create a card element
*/
function createCardElement(card) {
const cardElement = document.createElement('div');
cardElement.className = 'credit-card';
cardElement.dataset.id = card.id;
// Calculate days until cycle resets
const today = new Date();
const currentDay = today.getDate();
const cycleDay = parseInt(card.statementDate);
let daysUntilReset;
if (currentDay === cycleDay) {
daysUntilReset = 0;
} else if (currentDay < cycleDay) {
daysUntilReset = cycleDay - currentDay;
} else {
// Calculate days until next month's cycle date
const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
daysUntilReset = (lastDayOfMonth - currentDay) + cycleDay;
}
// Create card HTML
cardElement.innerHTML = `
<div class="card-header">
<div class="card-details">
<h2 class="card-title">${card.name}</h2>
<div class="card-bank">${card.bank}${card.lastDigits ? ` •••• ${card.lastDigits}` : ''}</div>
</div>
<div class="card-actions">
<button type="button" class="action-btn payment-btn" title="Record Payment" aria-label="Record Payment">💰</button>
<button type="button" class="action-btn edit-btn" title="Edit Card" aria-label="Edit Card"></button>
<button type="button" class="action-btn delete-btn" title="Delete Card" aria-label="Delete Card">🗑</button>
</div>
</div>
<div class="card-info">
<div class="info-row">
<span class="info-label">Statement Cycle:</span>
<span class="info-value">Day ${card.statementDate}</span>
</div>
<div class="info-row">
<span class="info-label">Cycle Resets:</span>
<span class="info-value">${daysUntilReset === 0 ? 'Today' : `In ${daysUntilReset} day${daysUntilReset !== 1 ? 's' : ''}`}</span>
</div>
${card.expiryDate ? `
<div class="info-row">
<span class="info-label">Expires:</span>
<span class="info-value">${formatExpiryDate(card.expiryDate)}</span>
</div>
` : ''}
</div>
<div class="rewards-categories">
${card.categories.map(category => {
const payments = category.payments; // Assumed to be an array now
const spent = payments.reduce((total, p) => total + parseFloat(p.amount), 0);
const hasLimit = category.limit > 0;
const percentUsed = hasLimit ? (spent / category.limit) * 100 : 0;
const isNearLimit = percentUsed >= 75 && percentUsed < 100;
const isAtLimit = percentUsed >= 100;
const hasPayments = payments.length > 0;
// Calculate cash back amounts
const cashbackEarned = (spent * category.rate / 100).toFixed(2);
const maxCashback = hasLimit ? (category.limit * category.rate / 100).toFixed(2) : 0;
const cashbackDisplay = hasLimit ? ` ($${cashbackEarned}/$${maxCashback})` : ` ($${cashbackEarned})`;
return `
<div class="category-item ${hasPayments ? 'has-payments' : ''}" data-category-name="${category.name}">
<div class="category-header">
<span class="category-tag">${category.name}: ${category.rate}%</span>
${hasLimit ? `
<span class="info-value">$${spent.toFixed(2)} / $${category.limit.toFixed(2)}${cashbackDisplay}</span>
` : `<span class="info-value">$${spent.toFixed(2)}${cashbackDisplay}</span>`}
</div>
${hasLimit ? `
<div class="progress-bar ${isNearLimit ? 'near-limit' : ''} ${isAtLimit ? 'at-limit' : ''}">
<div class="progress-fill" style="width: ${Math.min(percentUsed, 100)}%"></div>
</div>
` : ''}
${hasPayments ? `<div class="view-payments-link">View ${payments.length} payment${payments.length !== 1 ? 's' : ''}</div>` : ''}
</div>
`;
}).join('')}
</div>
`;
return cardElement;
}
/**
* Format expiry date (YYYY-MM to MM/YYYY)
*/
function formatExpiryDate(dateString) {
const [year, month] = dateString.split('-');
return `${month}/${year}`;
}
/**
* Format a date for display (YYYY-MM-DD to MM/DD/YYYY)
*/
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
}
/**
* Open modal to add a new card
*/
function openAddCardModal() {
// Reset form
cardForm.reset();
cardIdInput.value = '';
modalTitle.textContent = 'Add New Card';
// Clear existing category fields and add one empty one
clearCategoryFields();
// Open modal
openModal(cardModal);
}
/**
* Open modal to edit an existing card
*/
function openEditCardModal(card) {
// Set form values
cardForm.reset();
cardIdInput.value = card.id;
document.getElementById('card-name').value = card.name;
document.getElementById('card-bank').value = card.bank;
document.getElementById('last-digits').value = card.lastDigits || '';
document.getElementById('expiry-date').value = card.expiryDate || '';
document.getElementById('statement-date').value = card.statementDate;
modalTitle.textContent = 'Edit Card';
// Clear existing category fields
clearCategoryFields();
// Add category fields for existing categories
if (card.categories.length === 0) {
} else {
card.categories.forEach(category => {
addCategoryField(category.name, category.rate, category.limit);
});
}
// Open modal
openModal(cardModal);
}
/**
* Open modal to record a payment
*/
function openPaymentModal(card) {
paymentCardId.value = card.id;
paymentCardName.textContent = card.name;
// Clear and populate category dropdown
paymentCategory.innerHTML = '';
card.categories.forEach(category => {
if (!category) return;
const option = document.createElement('option');
option.value = category.name;
option.textContent = `${category.name} (${category.rate}%)`;
// Add info about limit if one exists
if (category.limit > 0) {
const payments = category.payments; // Assumed to be an array now
const spent = payments.reduce((total, p) => total + parseFloat(p.amount), 0);
const remaining = Math.max(0, category.limit - spent);
option.textContent += ` - $${remaining.toFixed(2)} remaining`;
}
paymentCategory.appendChild(option);
});
// Reset other fields
paymentAmount.value = '';
paymentDate.valueAsDate = new Date();
document.getElementById('payment-note').value = '';
// Set modal title and button text for new payment
paymentModal.querySelector('.modal-title').textContent = 'Record Payment';
paymentModal.querySelector('.save-btn').textContent = 'Record Payment';
// Open modal
openModal(paymentModal);
}
/**
* Open payment history modal for a category
*/
function openPaymentHistoryModal(card, category) {
if (!card || !category) return;
const container = document.getElementById('payment-history-container');
const modalTitle = paymentHistoryModal.querySelector('.modal-title');
modalTitle.textContent = `${card.name} - ${category.name} Payments`;
container.innerHTML = ''; // Clear previous content
const payments = category.payments; // Assumed to be an array now
if (payments.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No payment history</h3>
<p>No payments have been recorded for this category yet.</p>
</div>
`;
} else {
const sortedPayments = [...payments].sort((a, b) =>
new Date(b.date) - new Date(a.date)
);
const historyHtml = `
<div class="payment-history-list">
<div class="payment-history-header">
<span>Date</span>
<span>Amount</span>
<span>Note</span>
<span>Actions</span>
</div>
${sortedPayments.map(payment => `
<div class="payment-history-item" data-payment-id="${payment.id}">
<span class="payment-date">${formatDate(payment.date)}</span>
<span class="payment-amount">$${parseFloat(payment.amount).toFixed(2)}</span>
<span class="payment-note">${payment.note || '-'}</span>
<div class="payment-actions">
<button type="button" class="action-btn edit-payment-btn" title="Edit Payment" aria-label="Edit Payment"></button>
<button type="button" class="action-btn delete-payment-btn" title="Delete Payment" aria-label="Delete Payment">🗑</button>
</div>
</div>
`).join('')}
</div>
`;
container.innerHTML = historyHtml;
// Add event listeners for edit and delete payment buttons
container.querySelectorAll('.edit-payment-btn').forEach(btn => {
btn.addEventListener('click', e => {
const paymentId = e.target.closest('.payment-history-item').dataset.paymentId;
const payment = payments.find(p => p.id === paymentId);
if (payment) {
editPayment(card.id, category.name, payment);
}
});
});
container.querySelectorAll('.delete-payment-btn').forEach(btn => {
btn.addEventListener('click', e => {
const paymentId = e.target.closest('.payment-history-item').dataset.paymentId;
const payment = payments.find(p => p.id === paymentId);
if (payment) {
deletePayment(card.id, category.name, payment.id);
}
});
});
}
// Open the modal
openModal(paymentHistoryModal);
}
/**
* Edit a payment
*/
function editPayment(cardId, categoryName, payment) {
// Set up payment modal for editing
paymentCardId.value = cardId;
const card = cards.find(c => c.id === cardId);
if (!card) return;
paymentCardName.textContent = card.name;
// Populate category dropdown (with only the selected category)
paymentCategory.innerHTML = '';
const category = card.categories.find(c => c.name === categoryName);
if (!category) return;
const option = document.createElement('option');
option.value = category.name;
option.textContent = `${category.name} (${category.rate}%)`;
paymentCategory.appendChild(option);
// Set payment details
paymentAmount.value = payment.amount;
paymentDate.value = payment.date;
document.getElementById('payment-note').value = payment.note || '';
// Add payment ID to the form
const paymentIdInput = document.getElementById('payment-id') || document.createElement('input');
paymentIdInput.type = 'hidden';
paymentIdInput.id = 'payment-id';
paymentIdInput.value = payment.id;
if (!document.getElementById('payment-id')) {
paymentForm.appendChild(paymentIdInput);
}
// Change modal title and button text
paymentModal.querySelector('.modal-title').textContent = 'Edit Payment';
paymentModal.querySelector('.save-btn').textContent = 'Update Payment';
// Close history modal and open payment modal with a slight delay for better transition
closeModal(paymentHistoryModal);
setTimeout(() => {
openModal(paymentModal);
}, 300);
}
/**
* Delete a payment
*/
function deletePayment(cardId, categoryName, paymentId) {
if (!confirm('Are you sure you want to delete this payment?')) return;
const cardIndex = cards.findIndex(c => c.id === cardId);
if (cardIndex === -1) return;
const categoryIndex = cards[cardIndex].categories.findIndex(c => c.name === categoryName);
if (categoryIndex === -1) return;
// Remove the payment from the array
const payments = cards[cardIndex].categories[categoryIndex].payments; // Assumed to be array
const paymentIndex = payments.findIndex(p => p.id === paymentId);
if (paymentIndex === -1) return;
// Get payment amount for confirmation
const amount = payments[paymentIndex].amount;
// Remove the payment
payments.splice(paymentIndex, 1);
// Save changes
saveCards();
// Close the history modal
closeModal(paymentHistoryModal);
// Show confirmation toast
showToast(`Payment of $${amount.toFixed(2)} has been deleted`);
// Re-render cards
renderCards();
}
/**
* Open a modal
*/
function openModal(modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent background scrolling
// Focus first input in modal for better accessibility
setTimeout(() => {
const firstInput = modal.querySelector('input:not([type="hidden"])');
if (firstInput) firstInput.focus();
}, 100);
}
/**
* Close a modal
*/
function closeModal(modal) {
modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
}
/**
* Clear all category fields
*/
function clearCategoryFields() {
categoriesContainer.innerHTML = '';
}
/**
* Add a category field to the form
*/
function addCategoryField(name = '', rate = '', limit = '') {
const categoryField = document.createElement('div');
categoryField.className = 'category-inputs';
categoryField.innerHTML = `
<input type="text" class="form-input category-name" placeholder="Category (e.g. Dining)" value="${name}">
<input type="number" class="form-input category-rate" placeholder="Rate %" step="0.1" min="0" value="${rate}">
<input type="number" class="form-input category-limit" placeholder="Limit $" step="0.01" min="0" value="${limit}">
<button type="button" class="action-btn delete-btn remove-category" title="Remove Category" aria-label="Remove Category">×</button>
`;
// Add event listener to remove button
categoryField.querySelector('.remove-category').addEventListener('click', function() {
// Allow removing the last category field
categoryField.remove();
});
categoriesContainer.appendChild(categoryField);
}
/**
* Handle card form submission
*/
function handleCardFormSubmit(e) {
e.preventDefault();
// Get form values
const cardId = cardIdInput.value || generateId();
const isEditing = !!cardIdInput.value;
const name = document.getElementById('card-name').value;
const bank = document.getElementById('card-bank').value;
const lastDigits = document.getElementById('last-digits').value;
const expiryDate = document.getElementById('expiry-date').value;
const statementDate = document.getElementById('statement-date').value;
// Get categories
const categories = [];
const categoryInputs = categoriesContainer.querySelectorAll('.category-inputs');
categoryInputs.forEach(input => {
const catName = input.querySelector('.category-name').value.trim();
const catRate = input.querySelector('.category-rate').value.trim();
const catLimit = input.querySelector('.category-limit').value.trim();
// Only add this category if any reward detail is provided
if (!catName && !catRate && !catLimit) {
return;
}
// Determine payments: Use existing if editing and found, otherwise default to empty array
let payments = [];
const existingCardData = isEditing ? cards.find(c => c.id === cardId) : null;
if (isEditing && existingCardData && existingCardData.categories) {
const existingCategoryData = existingCardData.categories.find(c => c.name === catName);
if (existingCategoryData && Array.isArray(existingCategoryData.payments)) {
payments = existingCategoryData.payments;
}
}
categories.push({
name: catName,
rate: catRate ? parseFloat(catRate) : 0,
limit: catLimit ? parseFloat(catLimit) : null,
payments: payments // Ensured to be an array
});
});
// Create card object
const card = {
id: cardId,
name,
bank,
lastDigits,
expiryDate,
statementDate,
categories, // categories array now guaranteed to have .payments arrays
createdAt: new Date().toISOString()
};
// Update or add card
const existingCardIndex = isEditing ? cards.findIndex(c => c.id === cardId) : -1;
if (existingCardIndex !== -1) {
// Preserve archived payments if editing
card.archivedPayments = cards[existingCardIndex].archivedPayments;
cards[existingCardIndex] = card;
} else {
cards.push(card);
}
// Save and render cards
saveCards();
renderCards();
// Show feedback toast
showToast(isEditing ? 'Card updated successfully' : 'Card added successfully');
// Close modal
closeModal(cardModal);
}
/**
* Handle payment form submission
*/
function handlePaymentFormSubmit(e) {
e.preventDefault();
// Get form values
const cardId = paymentCardId.value;
const categoryName = paymentCategory.value;
const amount = parseFloat(paymentAmount.value);
const date = paymentDate.value;
const note = document.getElementById('payment-note').value;
const paymentId = document.getElementById('payment-id')?.value;
// Find card and category
const cardIndex = cards.findIndex(c => c.id === cardId);
if (cardIndex === -1) return;
const categoryIndex = cards[cardIndex].categories.findIndex(c => c.name === categoryName);
if (categoryIndex === -1) return;
// Check if editing existing payment or adding new one
const payments = cards[cardIndex].categories[categoryIndex].payments; // Assumed to be array
if (paymentId) {
// Find the payment
const paymentIndex = payments.findIndex(p => p.id === paymentId);
if (paymentIndex !== -1) {
// Update payment
payments[paymentIndex] = {
id: paymentId,
amount,
date,
note,
createdAt: payments[paymentIndex].createdAt, // Keep original creation date
updatedAt: new Date().toISOString()
};
// Show toast
showToast('Payment updated successfully');
}
} else {
// Add new payment
const payment = {
id: generateId(),
amount,
date,
note,
createdAt: new Date().toISOString()
};
payments.push(payment);
// Show toast
showToast(`Payment of $${amount.toFixed(2)} recorded`);
}
// Save and render cards
saveCards();
renderCards();
// Remove payment ID if it exists
if (document.getElementById('payment-id')) {
document.getElementById('payment-id').remove();
}
// Reset modal title
paymentModal.querySelector('.modal-title').textContent = 'Record Payment';
// Close modal
closeModal(paymentModal);
}
/**
* Delete a card
*/
function deleteCard(cardId) {
if (!confirm('Are you sure you want to delete this card?')) return;
const cardName = cards.find(card => card.id === cardId)?.name || 'Card';
cards = cards.filter(card => card.id !== cardId);
saveCards();
renderCards();
// Show feedback toast
showToast(`${cardName} has been deleted`);
}
/**
* Show a toast notification
*/
function showToast(message) {
// Remove existing toast if any
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// Create toast element
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Auto remove after 3 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* Handle search
*/
function handleSearch() {
renderCards();
}
/**
* Generate a unique ID
*/
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
/**
* Check for monthly cycle resets
*/
function checkMonthlyResets() {
const today = new Date();
const lastCheck = localStorage.getItem('lastCycleCheck');
// If we haven't checked today
if (!lastCheck || new Date(lastCheck).toDateString() !== today.toDateString()) {
const currentDay = today.getDate();
// Check each card for cycle reset
cards.forEach(card => {
const cycleDay = parseInt(card.statementDate);
// If today is the cycle reset day
if (currentDay === cycleDay) {
// Mark payments as from previous cycle
card.categories.forEach(category => {
// Only archive if there are payments
if (category.payments.length > 0) {
// Create an archive if none exists
if (!card.archivedPayments) {
card.archivedPayments = [];
}
// Archive current cycle payments
const cycleData = {
date: today.toISOString(),
categories: [{
name: category.name,
rate: category.rate,
payments: [...category.payments]
}]
};
// Add to archived payments
card.archivedPayments.push(cycleData);
// Clear current payments
category.payments = [];
}
});
}
});
// Save changes
saveCards();
// Update last check date
localStorage.setItem('lastCycleCheck', today.toISOString());
}
}
});

48
develop/js/docs.js Normal file
View file

@ -0,0 +1,48 @@
import { highlightElement } from 'https://unpkg.com/@speed-highlight/core@1.2.7/dist/index.js';
document.addEventListener('DOMContentLoaded', () => {
// Initialize Syntax Highlighting
initSyntaxHighlighting();
});
/**
* Initialize syntax highlighting using Speed Highlight JS
* This is a reusable function that applies syntax highlighting to code blocks
*/
async function initSyntaxHighlighting() {
try {
// Get all code blocks
const codeBlocks = document.querySelectorAll('.code-example pre code');
codeBlocks.forEach(block => {
// Determine language from code-label
let lang = 'html'; // Default to HTML
const example = block.closest('.code-example');
if (example) {
const label = example.querySelector('.code-label');
if (label) {
const labelText = label.textContent.trim().toLowerCase();
if (labelText === 'css') lang = 'css';
if (labelText === 'js' || labelText === 'javascript') lang = 'js';
if (labelText === 'go' || labelText === 'golang') lang = 'go';
if (labelText === 'json') lang = 'json';
if (labelText === 'http') lang = 'http';
}
}
// Create a new element to hold the highlighted code
const highlighted = document.createElement('div');
highlighted.className = `shj-lang-${lang}`;
highlighted.textContent = block.textContent;
// Replace the pre with our new element
const pre = block.parentElement;
pre.parentNode.replaceChild(highlighted, pre);
// Apply highlighting directly
highlightElement(highlighted, lang);
});
} catch (error) {
console.warn('Syntax highlighting failed to initialize:', error);
}
}

427
develop/js/lightbox.js Normal file
View file

@ -0,0 +1,427 @@
/**
* EasyLightbox - A simple, lightweight lightbox for images
*/
(function() {
// Default options
const defaultOptions = {
selector: '.lightbox-img, #flowDiagram', // Images that should trigger lightbox
captionAttribute: 'data-caption', // Attribute to retrieve caption from
zoomable: true, // Whether to enable zoom controls
maxZoom: 300, // Maximum zoom percentage
minZoom: 100, // Minimum zoom percentage
closeOnEsc: true, // Close on escape key
closeOnOutsideClick: true // Close when clicking outside image
};
// Create global object
window.EasyLightbox = {
options: { ...defaultOptions },
// Initialize with custom options
init: function(customOptions = {}) {
// Merge default options with custom options
this.options = { ...defaultOptions, ...customOptions };
// Create lightbox container if it doesn't exist
this._createLightbox();
// Initialize listeners for all matching elements
this._initImageListeners();
return this;
},
// Create the lightbox HTML structure if it doesn't exist
_createLightbox: function() {
// Check if lightbox already exists
if (document.getElementById("imageLightbox")) {
return;
}
// Create lightbox container
const lightbox = document.createElement("div");
lightbox.id = "imageLightbox";
lightbox.className = "lightbox";
// Create lightbox content with simplified HTML
lightbox.innerHTML = `
<div class="lightbox-content">
<div class="lightbox-close" id="lightboxClose">X</div>
<div class="lightbox-img-container">
<img class="lightbox-img" id="lightboxImg" src="" alt="Enlarged image" draggable="false">
</div>
<div class="lightbox-caption" id="lightboxCaption"></div>
${
this.options.zoomable
? `
<div class="zoom-controls">
<span class="zoom-label">Zoom:</span>
<input type="range" min="${this.options.minZoom}" max="${this.options.maxZoom}" value="100" class="zoom-slider" id="zoomSlider">
<span class="zoom-value" id="zoomValue">100%</span>
</div>`
: ""
}
</div>
`;
// Add lightbox CSS link if not already present
if (!document.getElementById("lightbox-styles")) {
const link = document.createElement("link");
link.id = "lightbox-styles";
link.rel = "stylesheet";
link.href = "/css/lightbox.css";
document.head.appendChild(link);
}
// Add to document
document.body.appendChild(lightbox);
// Cache DOM elements
this.elements = {
lightbox: lightbox,
lightboxImg: document.getElementById("lightboxImg"),
lightboxCaption: document.getElementById("lightboxCaption"),
lightboxClose: document.getElementById("lightboxClose"),
zoomSlider: document.getElementById("zoomSlider"),
zoomValue: document.getElementById("zoomValue")
};
// Initialize event handlers inside the lightbox
this._initLightboxHandlers();
},
// Initialize listeners for images that should open the lightbox
_initImageListeners: function() {
const images = document.querySelectorAll(this.options.selector);
const self = this;
images.forEach(img => {
// *** FIX: Skip the actual lightbox image itself ***
if (img.id === "lightboxImg") return;
// Skip if already initialized
if (img.dataset.lightboxInitialized) return;
img.dataset.lightboxInitialized = "true";
img.style.cursor = "pointer";
img.addEventListener("click", function() {
let caption = this.getAttribute(self.options.captionAttribute);
if (this.id === "flowDiagram" || !caption) {
caption = "Basic POW Flow Diagram";
}
self.open(this, caption);
});
});
// Special handling for flowDiagram if not caught by selector
const flowDiagram = document.getElementById("flowDiagram");
if (flowDiagram && !flowDiagram.dataset.lightboxInitialized) {
flowDiagram.dataset.lightboxInitialized = "true";
flowDiagram.style.cursor = "pointer";
flowDiagram.addEventListener("click", function() {
self.open(this, "Basic POW Flow Diagram");
});
}
},
// Initialize lightbox event handlers for zooming, closing, etc.
_initLightboxHandlers: function() {
const self = this;
const elements = this.elements;
let isDragging = false;
let startX, startY, startPanX, startPanY;
let panX = 0,
panY = 0;
// Add zoom slider handler
if (this.options.zoomable && elements.zoomSlider) {
elements.zoomSlider.addEventListener("input", function() {
const value = this.value;
elements.zoomValue.textContent = value + "%";
updateTransform();
});
}
// Add close button handler
if (elements.lightboxClose) {
elements.lightboxClose.addEventListener("click", function(e) {
e.preventDefault();
e.stopPropagation();
self.close();
});
}
// Add outside click handler
if (this.options.closeOnOutsideClick) {
elements.lightbox.addEventListener("click", function(e) {
if (e.target === elements.lightbox) {
self.close();
}
});
}
// Add escape key handler
if (this.options.closeOnEsc) {
document.addEventListener("keydown", function(e) {
if (e.key === "Escape" && elements.lightbox.classList.contains("active")) {
self.close();
}
});
}
// Add drag handlers for panning when zoomed
if (elements.lightboxImg) {
elements.lightboxImg.addEventListener("mousedown", startDrag);
elements.lightboxImg.addEventListener("touchstart", startDrag);
}
function startDrag(e) {
// Only allow dragging when zoomed in
if (!self.options.zoomable || parseInt(elements.zoomSlider.value) <= 100)
return;
e.preventDefault();
if (e.type === "touchstart") {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
} else {
startX = e.clientX;
startY = e.clientY;
}
startPanX = panX;
startPanY = panY;
isDragging = true;
elements.lightboxImg.classList.add("grabbing");
document.addEventListener("mousemove", doDrag);
document.addEventListener("touchmove", doDrag);
document.addEventListener("mouseup", stopDrag);
document.addEventListener("touchend", stopDrag);
document.addEventListener("mouseleave", stopDrag);
}
function doDrag(e) {
if (!isDragging) return;
// Prevent default scroll/zoom behavior on touch devices
e.preventDefault();
let clientX, clientY;
if (e.type === "touchmove") {
// Ensure there's a touch point
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const deltaX = clientX - startX;
const deltaY = clientY - startY;
panX = startPanX + deltaX;
panY = startPanY + deltaY;
// Apply the transform immediately for live dragging
updateTransform();
}
function stopDrag() {
if (!isDragging) return;
isDragging = false;
elements.lightboxImg.classList.remove("grabbing");
document.removeEventListener("mousemove", doDrag);
document.removeEventListener("touchmove", doDrag);
document.removeEventListener("mouseup", stopDrag);
document.removeEventListener("touchend", stopDrag);
document.removeEventListener("mouseleave", stopDrag);
}
function updateTransform() {
if (!self.options.zoomable) return;
const scale = parseInt(elements.zoomSlider.value) / 100;
elements.lightboxImg.style.transform = `scale(${scale}) translate(${
panX / scale
}px, ${panY / scale}px)`;
}
// Prevent scrolling on mobile when interacting with the lightbox
const isMobile = window.matchMedia(
"(max-width: 768px), (max-width: 1024px) and (orientation: landscape)"
).matches;
if (isMobile && elements.lightboxImg) {
elements.lightboxImg.addEventListener("touchmove", function(e) {
if (e.touches.length > 1) {
e.preventDefault();
}
});
}
},
// Open the lightbox with a specific image
open: async function(imageElement, caption) {
if (!imageElement || !this.elements) return;
const elements = this.elements;
let panX = 0, panY = 0;
// Remove any previous SVG
if (elements.lightboxImg && elements.lightboxImg.parentNode) {
elements.lightboxImg.style.display = '';
const prevSvg = elements.lightboxImg.parentNode.querySelector('svg.injected-svg');
if (prevSvg) prevSvg.remove();
}
const src = imageElement.src || imageElement.getAttribute("data-fullsize") || "";
const isSVG = src.toLowerCase().endsWith('.svg');
// Helper for zoom slider value
function getZoom() {
return elements.zoomSlider ? parseInt(elements.zoomSlider.value) / 100 : 1;
}
// Helper to update SVG transform
function updateSVGTransform(svg, svgPanX, svgPanY, scale) {
svg.style.transform = `scale(${scale}) translate(${svgPanX/scale}px, ${svgPanY/scale}px)`;
}
if (isSVG) {
elements.lightboxImg.style.display = 'none';
try {
const resp = await fetch(src);
let svgText = await resp.text();
const tempDiv = document.createElement('div');
tempDiv.innerHTML = svgText;
const svg = tempDiv.querySelector('svg');
if (svg) {
svg.classList.add('injected-svg');
svg.style.transformOrigin = 'center center';
svg.style.maxWidth = '100%';
svg.style.maxHeight = '100%';
svg.style.display = 'block';
svg.style.cursor = 'grab';
svg.style.userSelect = 'none';
svg.removeAttribute('width');
svg.removeAttribute('height');
elements.lightboxImg.parentNode.appendChild(svg);
// Set default zoom to 1.0x (100%)
if (elements.zoomSlider) {
elements.zoomSlider.value = 100;
elements.zoomValue.textContent = '100%';
}
let svgPanX = 0, svgPanY = 0;
let isDragging = false, startX, startY, startPanX = 0, startPanY = 0;
let currentScale = getZoom();
updateSVGTransform(svg, svgPanX, svgPanY, currentScale);
// Drag logic for SVG
svg.addEventListener('mousedown', startDrag);
svg.addEventListener('touchstart', startDrag);
function startDrag(e) {
e.preventDefault();
isDragging = true;
svg.classList.add('grabbing');
if (e.type === 'touchstart') {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
} else {
startX = e.clientX;
startY = e.clientY;
}
startPanX = svgPanX;
startPanY = svgPanY;
document.addEventListener('mousemove', doDrag);
document.addEventListener('touchmove', doDrag);
document.addEventListener('mouseup', stopDrag);
document.addEventListener('touchend', stopDrag);
document.addEventListener('mouseleave', stopDrag);
}
function doDrag(e) {
if (!isDragging) return;
e.preventDefault();
let clientX, clientY;
if (e.type === 'touchmove') {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const deltaX = clientX - startX;
const deltaY = clientY - startY;
svgPanX = startPanX + deltaX;
svgPanY = startPanY + deltaY;
updateSVGTransform(svg, svgPanX, svgPanY, getZoom());
}
function stopDrag() {
if (!isDragging) return;
isDragging = false;
svg.classList.remove('grabbing');
document.removeEventListener('mousemove', doDrag);
document.removeEventListener('touchmove', doDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('touchend', stopDrag);
document.removeEventListener('mouseleave', stopDrag);
}
// Zoom slider controls SVG scale
if (elements.zoomSlider) {
elements.zoomSlider.oninput = function() {
currentScale = getZoom();
elements.zoomValue.textContent = Math.round(currentScale * 100) + '%';
updateSVGTransform(svg, svgPanX, svgPanY, currentScale);
};
}
}
} catch (e) {
elements.lightboxImg.style.display = '';
}
} else {
elements.lightboxImg.src = src;
elements.lightboxImg.style.display = '';
elements.lightboxImg.style.transform = "scale(1) translate(0px, 0px)";
if (this.options.zoomable && elements.zoomSlider) {
elements.zoomSlider.value = 100;
elements.zoomValue.textContent = "100%";
}
}
const captionText =
caption ||
imageElement.getAttribute(this.options.captionAttribute) ||
imageElement.alt ||
imageElement.getAttribute("title") ||
"";
elements.lightboxCaption.textContent = captionText;
elements.lightbox.classList.add("active");
document.body.style.overflow = "hidden";
},
// Close the lightbox
close: function() {
if (!this.elements) return;
this.elements.lightbox.classList.remove("active");
document.body.style.overflow = "";
}
};
// Auto-initialize on load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function() {
window.EasyLightbox.init();
});
} else {
window.EasyLightbox.init();
}
})();

949
develop/js/lv.js Normal file
View file

@ -0,0 +1,949 @@
// Shared resources
const THUMBNAIL_CACHE = new Map();
const THUMBNAIL_REGISTRY = new Map();
const VIDEO_SERVICES = new Map();
// Common constants
const DEFAULT_ALLOW = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share";
const DEFAULT_SANDBOX = "allow-scripts allow-same-origin allow-popups allow-forms allow-presentation";
// Efficient image checking with modern fetch API
async function checkImage(url) {
if (THUMBNAIL_CACHE.has(url)) return THUMBNAIL_CACHE.get(url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(url, { method: 'HEAD', signal: controller.signal });
clearTimeout(timeoutId);
const valid = response.ok;
THUMBNAIL_CACHE.set(url, valid);
return valid;
} catch {
clearTimeout(timeoutId);
THUMBNAIL_CACHE.set(url, false);
return false;
}
}
// Helper for parsing URLs safely
function parseUrl(url) {
try { return new URL(url); } catch { return null; }
}
/**
* Service Provider base class - each video service extends this
*/
class VideoServiceProvider {
constructor() {
this.name = 'generic';
}
canHandle(url) { return false; }
getVideoId(url) { return null; }
getEmbedUrl(videoId, params, element) { return ''; }
getThumbnailUrls(videoId, quality, element) {
const customThumbnail = element.getAttribute("thumbnail");
return customThumbnail ? [customThumbnail] : [];
}
parseParams() { return {}; }
getIframeAttributes(element) {
return {
frameborder: element.getAttribute("frameborder") || "0",
allow: element.getAttribute("allow") || DEFAULT_ALLOW,
sandbox: element.getAttribute("sandbox") || DEFAULT_SANDBOX
};
}
getDefaults() { return { autoload: false }; }
}
/**
* YouTube service provider
*/
class YouTubeProvider extends VideoServiceProvider {
constructor() {
super();
this.name = 'youtube';
this.THUMBNAIL_QUALITIES = {
maxres: 'maxresdefault.jpg',
sd: 'sddefault.jpg',
hq: 'hqdefault.jpg',
mq: 'mqdefault.jpg',
default: 'default.jpg'
};
this.URL_PATTERNS = [
/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
/youtu\.be\/([a-zA-Z0-9_-]{11})/
];
}
canHandle(url) {
return url && /youtube\.com|youtu\.be/.test(url);
}
getVideoId(url) {
if (!url) return null;
const parsedUrl = parseUrl(url);
if (parsedUrl) {
// Path-based ID extraction (/embed/ID or youtu.be/ID)
if (parsedUrl.pathname.startsWith("/embed/") || parsedUrl.hostname === "youtu.be") {
const parts = parsedUrl.pathname.split("/");
return parts[parts.length > 2 ? 2 : 1];
}
// Query-based ID extraction (?v=ID)
const videoId = parsedUrl.searchParams.get("v");
if (videoId) return videoId;
}
// Fallback to regex matching
for (const pattern of this.URL_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) return match[1];
}
return null;
}
getEmbedUrl(videoId, params = {}, element) {
// Determine domain based on cookie preference
const useNoCookie = element.getAttribute("no-cookie") !== "false";
const domain = useNoCookie ? "youtube-nocookie.com" : "youtube.com";
// Build URL with parameters
let url = `https://www.${domain}/embed/${videoId}?autoplay=1`;
// Add parameters
for (const [key, value] of Object.entries(params)) {
if (key !== 'autoplay' && key && value) {
url += `&${key}=${encodeURIComponent(value)}`;
}
}
return url;
}
getThumbnailUrls(videoId, quality, element) {
// Check for custom thumbnail first
const customThumbnail = element.getAttribute("thumbnail");
if (customThumbnail) return [customThumbnail];
const baseUrl = `https://img.youtube.com/vi/${videoId}`;
const urls = [];
// Choose quality based on device and user preference
if (quality && this.THUMBNAIL_QUALITIES[quality]) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES[quality]}`);
} else if (window.matchMedia("(max-width: 767px)").matches) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`);
} else {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.maxres}`);
}
// Only add fallbacks if they're different from what we already have
if (!urls.includes(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`)) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`);
}
if (!urls.includes(`${baseUrl}/${this.THUMBNAIL_QUALITIES.default}`)) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.default}`);
}
return urls;
}
parseParams(url) {
const params = {};
const parsedUrl = parseUrl(url);
if (!parsedUrl) return params;
// Extract parameters from URL
for (const [key, value] of parsedUrl.searchParams.entries()) {
params[key] = value;
}
// Handle YouTube-specific parameters
if (params.t || params.start) params.start = params.t || params.start;
if (params.list) params.playlist = params.list;
return params;
}
}
/**
* Bitchute service provider
*/
class BitchuteProvider extends VideoServiceProvider {
constructor() {
super();
this.name = 'bitchute';
this.URL_PATTERNS = [
/bitchute\.com\/video\/([a-zA-Z0-9_-]+)/,
/bitchute\.com\/embed\/([a-zA-Z0-9_-]+)/
];
}
canHandle(url) {
return url && /bitchute\.com/.test(url);
}
getVideoId(url) {
if (!url) return null;
const parsedUrl = parseUrl(url);
if (parsedUrl) {
// Extract from path segments
const segments = parsedUrl.pathname.split('/').filter(Boolean);
for (let i = 0; i < segments.length - 1; i++) {
if ((segments[i] === "embed" || segments[i] === "video") && i + 1 < segments.length) {
return segments[i + 1];
}
}
}
// Fallback to regex matching
for (const pattern of this.URL_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) return match[1];
}
return null;
}
getEmbedUrl(videoId) {
return `https://www.bitchute.com/embed/${videoId}/`;
}
// Use parent class implementations for other methods
getDefaults() {
return { autoload: true };
}
}
// Register service providers
VIDEO_SERVICES.set('youtube', new YouTubeProvider());
VIDEO_SERVICES.set('bitchute', new BitchuteProvider());
class LazyVideo extends HTMLElement {
// Observable attributes
static get observedAttributes() {
return [
"src", "title", "width", "height", "thumbnail-quality",
"no-cookie", "autoload", "frameborder", "allow", "loading",
"hide-title", "thumbnail", "service", "align", "container-fit"
];
}
// CSS styles definition
static get styles() {
return `
:host {
--lv-aspect-ratio: 16 / 9;
display: var(--lv-display, block);
position: var(--lv-position, relative);
width: var(--lv-width, 100%);
max-width: var(--lv-max-width, 560px);
aspect-ratio: var(--lv-aspect-ratio);
background: var(--lv-background, #000);
overflow: var(--lv-overflow, hidden);
border-radius: var(--lv-border-radius, 0);
margin: var(--lv-margin, 0 auto);
}
:host([container-fit]) {
max-width: 100% !important;
max-height: auto !important;
width: 100%;
margin: 0;
}
/* Alignment control through attribute */
:host([align="left"]) { margin: var(--lv-margin-left, 0); }
:host([align="right"]) { margin: var(--lv-margin-right, 0 0 0 auto); }
:host([align="center"]) { margin: var(--lv-margin-center, 0 auto); }
/* Alignment classes for CSS variable-based alignment */
:host(.lv-align-left) { margin: var(--lv-margin-left, 0); }
:host(.lv-align-right) { margin: var(--lv-margin-right, 0 0 0 auto); }
:host(.lv-align-center) { margin: var(--lv-margin-center, 0 auto); }
:host([hide-title]), :host(:where(:not([hide-title]))) {
--lv-show-title: var(--lv-show-title, 1);
}
:host([hide-title]) [part="title-bar"] {
display: none;
}
:host([style*="height"]) { aspect-ratio: auto; }
[part="placeholder"], [part="iframe"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
[part="placeholder"] {
cursor: pointer;
background: var(--lv-placeholder-bg, #000);
}
[part="placeholder"]:focus {
outline: var(--lv-focus-outline, 2px solid #4285F4);
outline-offset: var(--lv-focus-outline-offset, 2px);
}
[part="thumbnail"] {
width: 100%;
height: 100%;
object-fit: var(--lv-thumbnail-object-fit, cover);
opacity: var(--lv-thumbnail-opacity, 0.85);
}
[part="placeholder"]:hover [part="thumbnail"],
[part="placeholder"]:focus [part="thumbnail"] {
opacity: var(--lv-thumbnail-hover-opacity, 1);
}
[part="title-bar"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: var(--lv-title-padding, 10px 12px);
background: var(--lv-title-bg, rgba(0, 0, 0, 0.75));
color: var(--lv-title-color, white);
font-family: var(--lv-title-font-family, Roboto, Arial, sans-serif);
font-size: var(--lv-title-font-size, 18px);
font-weight: var(--lv-title-font-weight, 500);
line-height: var(--lv-title-line-height, 1.2);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
z-index: 2;
box-sizing: border-box;
display: var(--lv-show-title, block);
}
[part="play-button"] {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--lv-play-button-width, 68px);
height: var(--lv-play-button-height, 48px);
background: var(--lv-play-button-bg, rgba(33, 33, 33, 0.8));
border-radius: var(--lv-play-button-radius, 8px);
}
[part="play-button"]::after {
content: '';
position: absolute;
top: 50%;
left: 55%;
transform: translate(-50%, -50%);
border-style: solid;
border-width: var(--lv-play-button-arrow-size, 12px 0 12px 20px);
border-color: transparent transparent transparent var(--lv-play-button-color, rgba(255, 255, 255, 0.9));
}
[part="placeholder"]:hover [part="play-button"] {
background: var(--lv-play-button-bg-hover, rgba(230, 33, 23, 1));
}
[part="timestamp"] {
position: absolute;
right: var(--lv-timestamp-right, 10px);
bottom: var(--lv-timestamp-bottom, 10px);
background: var(--lv-timestamp-bg, rgba(0, 0, 0, 0.7));
color: var(--lv-timestamp-color, white);
padding: var(--lv-timestamp-padding, 2px 6px);
border-radius: var(--lv-timestamp-radius, 3px);
font-size: var(--lv-timestamp-font-size, 12px);
font-family: var(--lv-timestamp-font-family, system-ui, sans-serif);
}
[part="iframe"] {
opacity: 0;
animation: fadeIn 0.3s ease forwards;
}
@keyframes fadeIn { to { opacity: 1; } }
[part="loading"], [part="fallback-thumbnail"] {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
}
[part="loading"] {
background: var(--lv-loading-bg, rgba(0,0,0,0.7));
color: var(--lv-loading-color, white);
font-family: var(--lv-loading-font-family, system-ui, sans-serif);
}
[part="fallback-thumbnail"] {
background: var(--lv-fallback-bg, #1a1a1a);
color: var(--lv-fallback-color, white);
font-family: var(--lv-fallback-font-family, system-ui, sans-serif);
font-size: var(--lv-fallback-font-size, 14px);
}
`;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this._loaded = false;
this._placeholder = null;
this._observer = null;
this._handlers = new Map();
this._videoService = null;
}
connectedCallback() {
if (!this.isConnected) return;
if (!this._loaded && !this._placeholder) {
this._createPlaceholder();
}
// Setup autoloading if needed
if (this._getServiceOption('autoload')) {
this._setupObserver();
}
// Check for alignment from CSS variables
this._updateAlignmentFromCSS();
// Set up mutation observer for style changes
this._setupStyleObserver();
}
disconnectedCallback() {
this._cleanupObserver();
this._cleanupEventHandlers();
// Clean up style observer
if (this._styleObserver) {
this._styleObserver.disconnect();
this._styleObserver = null;
}
// Cancel any animation frames
if (this._styleFrameId) {
cancelAnimationFrame(this._styleFrameId);
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this.isConnected) return;
switch (name) {
case "src":
if (oldValue !== newValue && newValue !== null) {
this._loaded = false;
this._createPlaceholder();
}
break;
case "width":
case "height":
this._updateStyles();
break;
case "autoload":
newValue === "true" || newValue === "" ? this._setupObserver() : this._cleanupObserver();
break;
case "thumbnail":
if (oldValue !== newValue) {
this._updateThumbnail();
}
break;
case "service":
if (oldValue !== newValue) {
this._loaded = false;
this._createPlaceholder();
}
break;
}
}
_getServiceProvider(url) {
// Check for explicit service attribute first
const serviceName = this.getAttribute("service");
if (serviceName && VIDEO_SERVICES.has(serviceName)) {
return VIDEO_SERVICES.get(serviceName);
}
// Auto-detect from URL
if (url) {
for (const provider of VIDEO_SERVICES.values()) {
if (provider.canHandle(url)) {
return provider;
}
}
}
// Default to YouTube if nothing else matches
return VIDEO_SERVICES.get('youtube');
}
_getServiceOption(option) {
// First check if attribute exists
if (this.hasAttribute(option)) {
const value = this.getAttribute(option);
// Handle boolean attributes
return value === "" || value === "true" || value !== "false";
}
// Then check service defaults
if (this._videoService?.getDefaults()[option] !== undefined) {
return this._videoService.getDefaults()[option];
}
return false;
}
_cleanupObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
_cleanupEventHandlers() {
this._handlers.forEach((handler, key) => {
const [element, event] = key.split('|');
if (element && element.removeEventListener) {
element.removeEventListener(event, handler);
}
});
this._handlers.clear();
}
_setupObserver() {
if (!window.IntersectionObserver) return;
this._cleanupObserver();
this._observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && !this._loaded) {
this._loadVideo();
this._cleanupObserver();
}
}, {
rootMargin: "300px",
threshold: 0.1
});
this._observer.observe(this);
}
_updateThumbnail() {
const img = this._placeholder?.querySelector('[part="thumbnail"]');
if (!img) return;
const customThumbnail = this.getAttribute("thumbnail");
if (customThumbnail) {
img.src = customThumbnail;
// Remove any fallback thumbnail if present
const fallback = this._placeholder.querySelector('[part="fallback-thumbnail"]');
if (fallback) fallback.remove();
return;
}
// Get service thumbnails
const videoId = this._placeholder.dataset.videoId;
if (videoId && this._videoService) {
const thumbnailQuality = this.getAttribute("thumbnail-quality");
const thumbnailUrls = this._videoService.getThumbnailUrls(videoId, thumbnailQuality, this);
if (thumbnailUrls.length > 0) {
this._loadThumbnail(thumbnailUrls, img);
} else {
this._createFallbackThumbnail();
}
}
}
_createFallbackThumbnail() {
if (!this._placeholder || this._placeholder.querySelector('[part="fallback-thumbnail"]')) {
return; // Already exists or no placeholder
}
const fallback = document.createElement('div');
fallback.setAttribute('part', 'fallback-thumbnail');
// Service-specific branding
if (this._videoService) {
const serviceName = this._videoService.name;
fallback.innerHTML = `
<div style="text-align: center;">
<div style="font-size: 18px; margin-bottom: 8px;">${serviceName.charAt(0).toUpperCase() + serviceName.slice(1)}</div>
<div>Click to play video</div>
</div>
`;
} else {
fallback.textContent = 'No thumbnail available';
}
this._placeholder.appendChild(fallback);
}
async _createPlaceholder() {
const src = this.getAttribute("src");
// Determine service provider & video ID
this._videoService = this._getServiceProvider(src);
const videoId = this._videoService?.getVideoId(src);
if (!videoId) {
this.shadowRoot.innerHTML = `
<style>:host{display:block;padding:10px;color:red;background:#222;border-radius:var(--lv-border-radius,0)}</style>
<p>Error: Can't find video ID. Check the 'src' attribute.</p>
`;
return;
}
// Get parameters and create elements
this._videoParams = this._videoService.parseParams(src);
const title = this.getAttribute("title") || "Video";
// Build Shadow DOM
const style = document.createElement("style");
style.textContent = LazyVideo.styles;
const placeholder = this._buildPlaceholder(videoId, title);
this.shadowRoot.innerHTML = '';
this.shadowRoot.append(style, placeholder);
this._updateStyles();
}
_buildPlaceholder(videoId, title) {
// Create placeholder container
const placeholder = document.createElement("div");
placeholder.setAttribute("part", "placeholder");
placeholder.setAttribute("role", "button");
placeholder.setAttribute("aria-label", `Play: ${title}`);
placeholder.setAttribute("tabindex", "0");
placeholder.dataset.videoId = videoId;
placeholder.dataset.service = this._videoService.name;
this._placeholder = placeholder;
// Create thumbnail image
const thumbnailQuality = this.getAttribute("thumbnail-quality");
const thumbnailUrls = this._videoService.getThumbnailUrls(videoId, thumbnailQuality, this);
// Add thumbnail image
const img = document.createElement("img");
img.setAttribute("part", "thumbnail");
img.alt = `Thumbnail for ${title}`;
img.loading = "lazy";
img.decoding = "async";
img.fetchPriority = "low";
img.style.backgroundColor = "#111";
placeholder.appendChild(img);
// Start thumbnail loading process
if (thumbnailUrls.length > 0) {
this._setupThumbnailObserver(img, thumbnailUrls);
} else {
this._createFallbackThumbnail();
}
// Add title bar if not disabled
if (!this.hasAttribute("hide-title")) {
const titleBar = document.createElement("div");
titleBar.setAttribute("part", "title-bar");
titleBar.textContent = title;
placeholder.appendChild(titleBar);
}
// Add play button
const playButton = document.createElement("div");
playButton.setAttribute("part", "play-button");
placeholder.appendChild(playButton);
// Add timestamp if present in params
const startTime = parseInt(this._videoParams.start || this._videoParams.t, 10);
if (!isNaN(startTime) && startTime > 0) {
const timestamp = document.createElement("div");
timestamp.setAttribute("part", "timestamp");
timestamp.textContent = this._formatTime(startTime);
placeholder.appendChild(timestamp);
}
// Set up interaction handlers
const handleInteraction = (e) => {
if (e.type === "click" || e.key === "Enter" || e.key === " ") {
if (e.type !== "click") e.preventDefault();
this._loadVideo();
}
};
placeholder.addEventListener("click", handleInteraction);
placeholder.addEventListener("keydown", handleInteraction);
// Track handlers for cleanup
this._handlers.set(`${placeholder}|click`, handleInteraction);
this._handlers.set(`${placeholder}|keydown`, handleInteraction);
return placeholder;
}
_setupThumbnailObserver(imgElement, urls) {
if (!window.IntersectionObserver) {
this._loadThumbnail(urls, imgElement);
return;
}
this._thumbnailLoadAttempted = false;
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting && !this._thumbnailLoadAttempted) {
this._thumbnailLoadAttempted = true;
try {
await this._loadThumbnail(urls, imgElement);
} catch {
this._thumbnailLoadAttempted = false;
} finally {
observer.disconnect();
}
}
}, {
rootMargin: "300px",
threshold: 0.1
});
observer.observe(imgElement);
}
async _loadThumbnail(urls, imgElement) {
// Custom thumbnails bypass validation
if (urls.length === 1 && urls[0] === this.getAttribute("thumbnail")) {
imgElement.src = urls[0];
return true;
}
// Cache key for shared thumbnails
const videoId = this._placeholder?.dataset?.videoId;
const service = this._placeholder?.dataset?.service;
const cacheKey = videoId && service ? `${service}:${videoId}` : null;
// Try to use cached result
if (cacheKey && THUMBNAIL_REGISTRY.has(cacheKey)) {
try {
const bestUrl = await THUMBNAIL_REGISTRY.get(cacheKey);
if (bestUrl) {
imgElement.src = bestUrl;
return true;
}
} catch {
THUMBNAIL_REGISTRY.delete(cacheKey);
}
}
// Find best thumbnail
let bestUrl = null;
// Try parallel loading first
try {
const results = await Promise.all(
urls.map(url => checkImage(url)
.then(valid => ({ url, valid }))
.catch(() => ({ valid: false }))
)
);
const bestResult = results.find(result => result.valid);
if (bestResult) bestUrl = bestResult.url;
} catch {
// Try sequential loading if parallel fails
for (const url of urls) {
try {
if (await checkImage(url)) {
bestUrl = url;
break;
}
} catch {}
}
}
// Set the best URL or create fallback
if (bestUrl) {
imgElement.src = bestUrl;
if (cacheKey) THUMBNAIL_REGISTRY.set(cacheKey, Promise.resolve(bestUrl));
return true;
} else {
this._createFallbackThumbnail();
if (cacheKey) THUMBNAIL_REGISTRY.set(cacheKey, Promise.resolve(null));
return false;
}
}
_formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0
? `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
: `${minutes}:${secs.toString().padStart(2, "0")}`;
}
_updateStyles() {
const width = this.getAttribute("width");
const height = this.getAttribute("height");
// Helper to check if a value already includes a CSS unit
const hasUnit = (value) => value && /[a-z%$]/.test(value);
if (width) {
this.style.setProperty("width", hasUnit(width) ? width : `${width}px`);
} else {
this.style.removeProperty("width");
}
if (height) {
this.style.setProperty("height", hasUnit(height) ? height : `${height}px`);
} else {
this.style.removeProperty("height");
}
// For aspect ratio, use numeric values from width/height if possible
if (width && height) {
const numericWidth = parseFloat(width);
const numericHeight = parseFloat(height);
if (!isNaN(numericWidth) && !isNaN(numericHeight)) {
this.style.setProperty("--lv-aspect-ratio", `${numericWidth} / ${numericHeight}`);
}
}
}
_loadVideo() {
if (this._loaded || !this._placeholder) return;
// Create loading indicator
const loading = document.createElement("div");
loading.setAttribute("part", "loading");
loading.textContent = "Loading...";
this.shadowRoot.appendChild(loading);
const videoId = this._placeholder.dataset.videoId;
const title = this.getAttribute("title") || "Video";
// Get the service if not already set
if (!this._videoService) {
const serviceName = this._placeholder.dataset.service;
this._videoService = VIDEO_SERVICES.get(serviceName) || VIDEO_SERVICES.get('youtube');
}
// Get embed URL and create iframe
const url = this._videoService.getEmbedUrl(videoId, this._videoParams, this);
// Create iframe
const iframe = document.createElement("iframe");
iframe.setAttribute("part", "iframe");
iframe.loading = "lazy";
iframe.src = url;
iframe.title = title;
// Add credentialless attribute for enhanced security
iframe.setAttribute("credentialless", "");
// Add service-specific attributes
const iframeAttrs = this._videoService.getIframeAttributes(this);
for (const [name, value] of Object.entries(iframeAttrs)) {
iframe.setAttribute(name, value);
}
// Handle loading indicator removal
const loadHandler = () => loading.parentNode?.removeChild(loading);
iframe.addEventListener("load", loadHandler, { once: true });
this._handlers.set(`${iframe}|load`, loadHandler);
// Replace placeholder with iframe
this._placeholder.replaceWith(iframe);
this._loaded = true;
this._placeholder = null;
// Notify that video is loaded
this.dispatchEvent(new CustomEvent("video-loaded", {
bubbles: true,
detail: { videoId, service: this._videoService.name }
}));
}
_setupStyleObserver() {
if (this._styleObserver) return;
// Create a MutationObserver to watch for style attribute changes
this._styleObserver = new MutationObserver(() => {
this._updateAlignmentFromCSS();
});
this._styleObserver.observe(this, {
attributes: true,
attributeFilter: ['style']
});
// Also observe document/body style changes that might affect CSS variables
if (window.getComputedStyle) {
// Use requestAnimationFrame to limit performance impact
let frameId;
const checkStyles = () => {
frameId = requestAnimationFrame(() => {
this._updateAlignmentFromCSS();
frameId = requestAnimationFrame(checkStyles);
});
};
checkStyles();
// Store the frame ID for cleanup
this._styleFrameId = frameId;
}
}
_updateAlignmentFromCSS() {
if (this.hasAttribute('container-fit')) return;
// Get computed style
const computedStyle = window.getComputedStyle(this);
const alignValue = computedStyle.getPropertyValue('--lv-align').trim();
// Remove existing alignment classes
this.classList.remove('lv-align-left', 'lv-align-right', 'lv-align-center');
// Add appropriate class based on the CSS variable
if (alignValue === 'left') {
this.classList.add('lv-align-left');
} else if (alignValue === 'right') {
this.classList.add('lv-align-right');
} else if (alignValue === 'center') {
this.classList.add('lv-align-center');
}
}
}
// Register the component
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => customElements.define("lazy-video", LazyVideo));
} else {
customElements.define("lazy-video", LazyVideo);
}

132
develop/js/u.js Normal file
View file

@ -0,0 +1,132 @@
// Smooth scroll to ID
window.addEventListener("load", function () { setTimeout(() => { if (window.location.hash) { let t = window.location.hash.substring(1), o = document.getElementById(t); o && o.scrollIntoView({ behavior: "smooth", block: "start" }) } }, 135) });
// No card hover on touch
("ontouchstart" in window || navigator.maxTouchPoints > 0) && window.addEventListener("touchstart", function t() { document.body.classList.add("no-hover"), window.removeEventListener("touchstart", t, !1) }, !1);
// Auto-add target="_blank" and secure rel (noopener & noreferrer) to external links,
// except those with the "eel" class
(() => { let e = document.baseURI, t = document.querySelectorAll("a[href]:not(.eel)"), r = window.location.hostname; for (let l = 0, n = t.length; l < n; l++) { let o = t[l]; try { let b = new URL(o.getAttribute("href"), e); if (b.hostname !== r) { "_blank" !== o.getAttribute("target") && o.setAttribute("target", "_blank"); let a = o.getAttribute("rel") || ""; /\bnoopener\b/.test(a) || (a += " noopener"), /\bnoreferrer\b/.test(a) || (a += " noreferrer"), o.setAttribute("rel", a.trim()) } } catch (i) { } } })();
// Switch to JPG for devices that don't support WebP
!async function () { await async function () { return new Promise((function (n) { const e = new Image; e.onload = function () { n(1 === e.width && 1 === e.height) }, e.onerror = function () { n(!1) }, e.src = "data:image/webp;base64,UklGRhYAAABXRUJQVlA4TAoAAAAvAAAAAEX/I/of" })) }() || document.querySelectorAll('img[src$=".webp"]').forEach((function (n) { n.src = n.src.replace(/\.webp$/i, ".jpg") })) }()
// Link redirect animation
document.addEventListener('DOMContentLoaded', function () {
// Create and inject CSS for the animation
const style = document.createElement('style');
style.textContent = `
.link-arrow-container {
position: absolute;
pointer-events: none;
z-index: 9999;
width: 20px;
height: 20px;
right: 0px;
opacity: 0;
transform: translateX(-5px);
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
/* Vertical alignment handled by parent flex settings */
}
.link-arrow-container.animate {
opacity: 1;
transform: translateX(5px);
}
.link-arrow-container svg {
width: 100%;
height: 100%;
fill: currentColor;
display: block;
}
a[href]:not(.no-arrow-padding):not(a[target="_blank"]) {
position: relative;
padding-right: 24px;
display: inline-flex;
align-items: center;
}
`;
document.head.appendChild(style);
// SVG arrow icon data
const svgArrow = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>`;
// Add arrow containers to eligible links on load
document.querySelectorAll('a[href]').forEach(link => {
const href = link.getAttribute('href');
// Skip links that open in new tabs, are anchors, or javascript calls
if (link.getAttribute('target') === '_blank' || href.startsWith('#') || href.startsWith('javascript:')) {
return;
}
// Create and append arrow container
const arrowContainer = document.createElement('div');
arrowContainer.className = 'link-arrow-container';
arrowContainer.innerHTML = svgArrow;
link.appendChild(arrowContainer);
});
// Delegated click listener on the body
document.body.addEventListener('click', function (e) {
// Find the closest ancestor link
const link = e.target.closest('a[href]');
// If no link was clicked, or checks fail, do nothing
if (!link) return;
const href = link.getAttribute('href');
if (link.getAttribute('target') === '_blank' || href.startsWith('#') || href.startsWith('javascript:')) {
return;
}
// Skip if modifier keys are pressed
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
// Find the arrow container within this link
const arrowContainer = link.querySelector('.link-arrow-container');
if (!arrowContainer) return; // Should exist, but safety check
// Prevent default navigation
e.preventDefault();
// Animate the arrow
arrowContainer.classList.add('animate');
// Navigate after a delay
setTimeout(() => {
window.location.href = href;
}, 100);
});
// Reset animation state on page show (handles bfcache)
window.addEventListener('pageshow', function (event) {
if (event.persisted) {
document.querySelectorAll('.link-arrow-container.animate').forEach(arrow => {
arrow.classList.remove('animate');
});
}
});
});
// Quicklink 2.3.0
!function (e, n) { "object" == typeof exports && "undefined" != typeof module ? n(exports) : "function" == typeof define && define.amd ? define(["exports"], n) : n(e.quicklink = {}) }(this, function (e) { function n(e) { return new Promise(function (n, r, t) { (t = new XMLHttpRequest).open("GET", e, t.withCredentials = !0), t.onload = function () { 200 === t.status ? n() : r() }, t.send() }) } var r, t = (r = document.createElement("link")).relList && r.relList.supports && r.relList.supports("prefetch") ? function (e) { return new Promise(function (n, r, t) { (t = document.createElement("link")).rel = "prefetch", t.href = e, t.onload = n, t.onerror = r, document.head.appendChild(t) }) } : n, o = window.requestIdleCallback || function (e) { var n = Date.now(); return setTimeout(function () { e({ didTimeout: !1, timeRemaining: function () { return Math.max(0, 50 - (Date.now() - n)) } }) }, 1) }, i = new Set, c = new Set, u = !1; function a(e) { if (e) { if (e.saveData) return new Error("Save-Data is enabled"); if (/2g/.test(e.effectiveType)) return new Error("network conditions are poor") } return !0 } function s(e, r, o) { var s = a(navigator.connection); return s instanceof Error ? Promise.reject(new Error("Cannot prefetch, " + s.message)) : (c.size > 0 && !u && console.warn("[Warning] You are using both prefetching and prerendering on the same document"), Promise.all([].concat(e).map(function (e) { if (!i.has(e)) return i.add(e), (r ? function (e) { return window.fetch ? fetch(e, { credentials: "include" }) : n(e) } : t)(new URL(e, location.href).toString()) }))) } function f(e, n) { var r = a(navigator.connection); if (r instanceof Error) return Promise.reject(new Error("Cannot prerender, " + r.message)); if (!HTMLScriptElement.supports("speculationrules")) return s(e), Promise.reject(new Error("This browser does not support the speculation rules API. Falling back to prefetch.")); if (document.querySelector('script[type="speculationrules"]')) return Promise.reject(new Error("Speculation Rules is already defined and cannot be altered.")); for (var t = 0, o = [].concat(e); t < o.length; t += 1) { var f = o[t]; if (window.location.origin !== new URL(f, window.location.href).origin) return Promise.reject(new Error("Only same origin URLs are allowed: " + f)); c.add(f) } i.size > 0 && !u && console.warn("[Warning] You are using both prefetching and prerendering on the same document"); var l = function (e) { var n = document.createElement("script"); n.type = "speculationrules", n.text = '{"prerender":[{"source": "list","urls": ["' + Array.from(e).join('","') + '"]}]}'; try { document.head.appendChild(n) } catch (e) { return e } return !0 }(c); return !0 === l ? Promise.resolve() : Promise.reject(l) } e.listen = function (e) { if (e || (e = {}), window.IntersectionObserver) { var n = function (e) { e = e || 1; var n = [], r = 0; function t() { r < e && n.length > 0 && (n.shift()(), r++) } return [function (e) { n.push(e) > 1 || t() }, function () { r--, t() }] }(e.throttle || 1 / 0), r = n[0], t = n[1], a = e.limit || 1 / 0, l = e.origins || [location.hostname], d = e.ignores || [], h = e.delay || 0, p = [], m = e.timeoutFn || o, w = "function" == typeof e.hrefFn && e.hrefFn, g = e.prerender || !1; u = e.prerenderAndPrefetch || !1; var v = new IntersectionObserver(function (n) { n.forEach(function (n) { if (n.isIntersecting) p.push((n = n.target).href), function (e, n) { n ? setTimeout(e, n) : e() }(function () { -1 !== p.indexOf(n.href) && (v.unobserve(n), (u || g) && c.size < 1 ? f(w ? w(n) : n.href).catch(function (n) { if (!e.onError) throw n; e.onError(n) }) : i.size < a && !g && r(function () { s(w ? w(n) : n.href, e.priority).then(t).catch(function (n) { t(), e.onError && e.onError(n) }) })) }, h); else { var o = p.indexOf((n = n.target).href); o > -1 && p.splice(o) } }) }, { threshold: e.threshold || 0 }); return m(function () { (e.el || document).querySelectorAll("a").forEach(function (e) { l.length && !l.includes(e.hostname) || function e(n, r) { return Array.isArray(r) ? r.some(function (r) { return e(n, r) }) : (r.test || r).call(r, n.href, n) }(e, d) || v.observe(e) }) }, { timeout: e.timeout || 2e3 }), function () { i.clear(), v.disconnect() } } }, e.prefetch = s, e.prerender = f });
quicklink.listen({
origins: [],
ignores: [
// Don't prefetch URL fragments from my own site
uri => uri.includes('caileb.com') && uri.includes('#'),
// Don't prefetch hosted services
uri => uri.includes('gallery.caileb.com'),
uri => uri.includes('jellyfin.caileb.com'),
uri => uri.includes('archive.caileb.com'),
uri => uri.includes('music.caileb.com'),
// Don't prefetch API's
/\/api\/?/,
/^api\./,
// Don't prefetch these file types
uri => /\.(zip|tar|7z|rar|js|apk|xapk|woff2|tff|otf|pdf|mp3|mp4|wav|exe|msi|bat|deb|rpm|bin|dmg|iso|csv|log|sql|xml|key|odp|ods|pps|ppt|xls|doc|jpg|jpeg|jpe|jif|jfif|jfi|png|gif|webp|tif|psd|raw|arw|cr2|nrw|k25|bmp|dib|heif|heic|ind|indd|indt|jp2|j2k|jpf|jpx|jpm|mj2|svg|ai|eps)$/i.test(uri),
// Don't prefetch these protocols
uri => /^(http|file|ftp|mailto|tel):/i.test(uri),
]
});