Import existing project
This commit is contained in:
parent
7887817595
commit
80b0cc4939
125 changed files with 16980 additions and 0 deletions
396
develop/js/c.js
Normal file
396
develop/js/c.js
Normal 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
858
develop/js/cc.js
Normal 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">×</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
48
develop/js/docs.js
Normal 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
427
develop/js/lightbox.js
Normal 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
949
develop/js/lv.js
Normal 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
132
develop/js/u.js
Normal 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),
|
||||
]
|
||||
});
|
||||
Reference in a new issue