Pan Mode - Drag to move image
Vavlo
Expires: -
Accesses: 0

Drag and drop your image here

or click to browse files
`; } // Elements const dropzone = document.getElementById('dropzone'); const chooseFileBtn = document.getElementById('chooseFile'); const fileInput = document.getElementById('fileInput'); const stage = document.getElementById('stage'); const img = document.getElementById('img'); const imageInner = document.getElementById('imageInner'); const viewport = document.getElementById('viewport'); const canvasArea = document.querySelector('.canvas-area'); const commentsEl = document.getElementById('comments'); const filterSelect = document.getElementById('filterSelect'); const zoomInBtn = document.getElementById('zoomIn'); const zoomOutBtn = document.getElementById('zoomOut'); const zoomResetBtn = document.getElementById('zoomReset'); const versionSelect = document.getElementById('version'); const uploadBtn = document.getElementById('uploadNewVersion'); const expiresText = document.getElementById('expiresText'); const accessText = document.getElementById('accessText'); const shareInput = document.getElementById('shareLink'); const copyBtn = document.getElementById('copyShare'); const downloadBtn = document.getElementById('downloadHTML'); const panIndicator = document.getElementById('panIndicator'); // Versions function setVersions(list){ const uniq = Array.from(new Set(list)); uniq.sort((a,b)=> (parseInt(b.replace(/\D/g,''))||0) - (parseInt(a.replace(/\D/g,''))||0)).reverse(); state.versionOrder = uniq; versionSelect.innerHTML = ''; if (uniq.length === 0) { const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = 'v0'; versionSelect.appendChild(defaultOpt); } else { uniq.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; versionSelect.appendChild(opt); }); } if (state.currentVersion == null && uniq.length){ state.currentVersion = uniq[0]; } versionSelect.value = state.currentVersion || ''; } versionSelect.addEventListener('change', ()=>{ const v = versionSelect.value; if (!v) return; state.currentVersion = v; renderAll(); }); // Smart zoom system function getSmartZoomLevel(direction) { const currentZoom = state.scale; const zoomLevels = [0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5]; if (direction === 'in') { // Find the next higher zoom level for (let level of zoomLevels) { if (level > currentZoom + 0.01) { // small tolerance for float comparison return level; } } return state.maxScale; } else { // Find the next lower zoom level for (let i = zoomLevels.length - 1; i >= 0; i--) { if (zoomLevels[i] < currentZoom - 0.01) { return zoomLevels[i]; } } return state.minScale; } } // Zoom and fit with proper scrollable content creation function applyScale(){ imageInner.style.transform = `scale(${state.scale})`; imageInner.style.setProperty('--z', String(state.scale)); // keep markers/tooltips same visual size zoomResetBtn.textContent = Math.round(state.scale * 100) + '%'; if (state.imgNatural.h && state.imgNatural.w){ // Create scrollable content by making viewport larger than canvas-area const scaledWidth = state.imgNatural.w * state.scale; const scaledHeight = state.imgNatural.h * state.scale; // Ensure viewport is at least as large as the scaled image const minWidth = Math.max(scaledWidth, canvasArea.clientWidth); const minHeight = Math.max(scaledHeight, canvasArea.clientHeight); viewport.style.width = minWidth + 'px'; viewport.style.height = minHeight + 'px'; viewport.style.minWidth = scaledWidth + 'px'; viewport.style.minHeight = scaledHeight + 'px'; console.log('Applied scale with scrollable content:', { scale: state.scale, natural: { w: state.imgNatural.w, h: state.imgNatural.h }, scaled: { w: scaledWidth, h: scaledHeight }, viewport: { w: minWidth, h: minHeight }, canvasArea: { w: canvasArea.clientWidth, h: canvasArea.clientHeight }, scrollable: { w: canvasArea.scrollWidth, h: canvasArea.scrollHeight } }); } } function fitToViewport(){ if (!state.versions[state.currentVersion]) return; const vw = viewport.clientWidth; // fit width so we avoid horizontal scroll const sx = vw / state.imgNatural.w; // cap at 1 so we do not upscale by default state.scale = Math.min(1, Math.max(sx, 0.1)); applyScale(); } zoomInBtn.addEventListener('click', ()=>{ state.scale = getSmartZoomLevel('in'); applyScale(); }); zoomOutBtn.addEventListener('click', ()=>{ state.scale = getSmartZoomLevel('out'); applyScale(); }); zoomResetBtn.addEventListener('click', fitToViewport); window.addEventListener('resize', ()=>{ if (!stage.hidden) fitToViewport(); }); // Improved spacebar panning functionality for Mac compatibility function updatePanMode() { if (state.spacePressed && !stage.hidden) { viewport.classList.add('panning'); canvasArea.classList.add('panning'); panIndicator.classList.add('active'); console.log('🎯 Pan mode enabled'); } else { viewport.classList.remove('panning'); canvasArea.classList.remove('panning'); panIndicator.classList.remove('active'); state.panning = false; canvasArea.classList.remove('pan-active'); console.log('🎯 Pan mode disabled'); } } // Use both keydown and keyup for better cross-platform support document.addEventListener('keydown', (e) => { // Check for spacebar - use both 'Space' and ' ' for compatibility if ((e.code === 'Space' || e.key === ' ') && !e.repeat) { // Don't interfere with input fields if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.contentEditable === 'true') { return; } // Prevent ALL default spacebar behavior e.preventDefault(); e.stopPropagation(); state.spacePressed = true; updatePanMode(); } }); document.addEventListener('keyup', (e) => { if (e.code === 'Space' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); state.spacePressed = false; updatePanMode(); } }); // Prevent spacebar scrolling globally window.addEventListener('keydown', (e) => { if ((e.code === 'Space' || e.key === ' ') && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA' && e.target.contentEditable !== 'true') { e.preventDefault(); } }, true); // Focus management to ensure spacebar works window.addEventListener('focus', () => { state.spacePressed = false; updatePanMode(); }); // Pan mode with improved event handling and debugging canvasArea.addEventListener('mousedown', (e) => { if (state.spacePressed && !stage.hidden) { e.preventDefault(); e.stopPropagation(); state.panning = true; state.panStart = { x: e.clientX, y: e.clientY }; state.scrollStart = { x: canvasArea.scrollLeft, y: canvasArea.scrollTop }; canvasArea.classList.add('pan-active'); console.log('Pan started:', { panStart: state.panStart, scrollStart: state.scrollStart, canvasScrollable: { scrollWidth: canvasArea.scrollWidth, scrollHeight: canvasArea.scrollHeight, clientWidth: canvasArea.clientWidth, clientHeight: canvasArea.clientHeight, scrollLeft: canvasArea.scrollLeft, scrollTop: canvasArea.scrollTop }, viewportSize: { width: viewport.clientWidth, height: viewport.clientHeight } }); } }); // Use both mousemove events for better coverage canvasArea.addEventListener('mousemove', (e) => { if (state.panning && state.spacePressed) { e.preventDefault(); e.stopPropagation(); const deltaX = state.panStart.x - e.clientX; const deltaY = state.panStart.y - e.clientY; const newScrollLeft = state.scrollStart.x + deltaX; const newScrollTop = state.scrollStart.y + deltaY; canvasArea.scrollLeft = newScrollLeft; canvasArea.scrollTop = newScrollTop; console.log('Local pan event:', { delta: { x: deltaX, y: deltaY }, scroll: { left: canvasArea.scrollLeft, top: canvasArea.scrollTop } }); } }); document.addEventListener('mousemove', (e) => { if (state.panning && state.spacePressed) { e.preventDefault(); e.stopPropagation(); const deltaX = state.panStart.x - e.clientX; const deltaY = state.panStart.y - e.clientY; const newScrollLeft = state.scrollStart.x + deltaX; const newScrollTop = state.scrollStart.y + deltaY; canvasArea.scrollLeft = newScrollLeft; canvasArea.scrollTop = newScrollTop; console.log('Global pan event:', { delta: { x: deltaX, y: deltaY }, scroll: { left: canvasArea.scrollLeft, top: canvasArea.scrollTop } }); } }); document.addEventListener('mouseup', (e) => { if (state.panning) { e.preventDefault(); state.panning = false; canvasArea.classList.remove('pan-active'); console.log('Pan ended'); } }); // Mouse wheel zoom viewport.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); if (e.deltaY < 0) { state.scale = getSmartZoomLevel('in'); } else { state.scale = getSmartZoomLevel('out'); } applyScale(); } }); // Uploads - Fix double click issue chooseFileBtn.addEventListener('click', (e) => { e.stopPropagation(); // prevent dropzone click fileInput.click(); }); // Only add click to dropzone area, not the button dropzone.addEventListener('click', (e) => { if (e.target === chooseFileBtn || chooseFileBtn.contains(e.target)) { return; // don't trigger if clicking the button } fileInput.click(); }); dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.style.borderColor = 'var(--pink)'; }); dropzone.addEventListener('dragleave', ()=> { dropzone.style.borderColor = 'var(--line)'; }); dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.style.borderColor = 'var(--line)'; const file = e.dataTransfer.files && e.dataTransfer.files[0]; if (file) addNewVersionFromFile(file); }); fileInput.addEventListener('change', e => { const file = e.target.files && e.target.files[0]; if (file) addNewVersionFromFile(file); }); uploadBtn.addEventListener('click', () => { if (state.sharedMode) { alert('Uploading new versions is disabled for shared viewers.'); return; } fileInput.click(); }); async function ensureProjectIdAndLink(){ if (!state.projectId){ state.projectId = cryptoRandomId(64); state.createdAt = new Date().toISOString(); state.expiresAt = new Date(Date.now() + 90*24*60*60*1000).toISOString(); state.accessCount = 0; console.log('Created new project:', state.projectId); } // Clean share link format - just use the project ID const appUrl = `${location.origin}${location.pathname}?shared=${state.projectId}`; if (shareInput){ shareInput.value = appUrl; console.log('🔗 Set share link to clean format:', appUrl); } await saveProject(); } async function addNewVersionFromFile(file){ if (state.sharedMode){ alert('Uploading new versions is disabled for shared viewers.'); return; } if (!file) return; const url = URL.createObjectURL(file); await ensureProjectIdAndLink(); await addVersionFromImageURL(url); } async function addVersionFromImageURL(url){ const imgLoad = new Image(); imgLoad.onload = () => { const nextNum = Math.max(0, ...state.versionOrder.map(v=> parseInt(v.replace(/\D/g,''))||0)) + 1; const vKey = `v${nextNum}`; state.versions[vKey] = { imageSrc: url, comments: [], nextId: 1 }; state.imgNatural.w = imgLoad.naturalWidth; state.imgNatural.h = imgLoad.naturalHeight; imageInner.style.width = state.imgNatural.w + 'px'; imageInner.style.height = state.imgNatural.h + 'px'; setVersions([vKey, ...state.versionOrder]); state.currentVersion = vKey; stage.hidden = false; dropzone.hidden = true; renderAll(); fitToViewport(); // FORCE clean share link format here too if (shareInput && state.projectId) { const cleanUrl = `${location.origin}${location.pathname}?shared=${state.projectId}`; shareInput.value = cleanUrl; console.log('Set clean share URL after image upload:', cleanUrl); } autoSave(); }; imgLoad.src = url; } imageInner.addEventListener('click', (e) => { if (e.target.classList.contains('marker')) return; if (state.panning || state.spacePressed) return; // don't add comments while panning const rect = imageInner.getBoundingClientRect(); const xScaled = e.clientX - rect.left; const yScaled = e.clientY - rect.top; const x = xScaled / state.scale; const y = yScaled / state.scale; const xPct = (x / state.imgNatural.w) * 100; const yPct = (y / state.imgNatural.h) * 100; openNewCommentForm(xPct, yPct); }); // Pending marker helpers function showPendingMarker(xPct, yPct){ const existing = imageInner.querySelector('.marker.pending'); if (existing) existing.remove(); const el = document.createElement('div'); el.className = 'marker unresolved pending'; el.style.left = xPct + '%'; el.style.top = yPct + '%'; el.textContent = '+'; imageInner.appendChild(el); } function removePendingMarker(){ const existing = imageInner.querySelector('.marker.pending'); if (existing) existing.remove(); } function openNewCommentForm(xPct, yPct){ const namePrefill = getSavedAuthor(); const container = document.createElement('div'); container.className = 'new-comment comment'; container.innerHTML = `
New Comment
`; container.dataset.xpct = String(xPct); container.dataset.ypct = String(yPct); commentsEl.prepend(container); showPendingMarker(xPct, yPct); } commentsEl.addEventListener('click', (e)=>{ const t = e.target; if (!(t instanceof HTMLElement)) return; const action = t.getAttribute('data-action'); if (action === 'save-new'){ const wrap = t.closest('.new-comment'); if (!wrap) return; const xPct = +wrap.dataset.xpct; const yPct = +wrap.dataset.ypct; const author = (wrap.querySelector('.author-name').value || 'Anon').trim(); const text = (wrap.querySelector('.author-text').value || '').trim(); if (!text){ alert('Please add a comment.'); return; } setSavedAuthor(author); addComment({ xPct, yPct, author, text, resolved:false }); wrap.remove(); removePendingMarker(); return; } if (action === 'cancel-new'){ const wrap = t.closest('.new-comment'); if (wrap) wrap.remove(); removePendingMarker(); return; } const id = +(t.getAttribute('data-id') || 0); if (!id) return; const v = state.versions[state.currentVersion]; const c = v.comments.find(x=> x.id === id); if (!c) return; if (action === 'toggle-resolve'){ c.resolved = !c.resolved; renderMarker(c); renderComments(); autoSave(); return; } if (action === 'delete'){ if (!confirm('Delete this comment?')) return; v.comments = v.comments.filter(x=> x.id !== id); const m = imageInner.querySelector(`[data-id="${id}"]`); if (m) m.remove(); renderComments(); autoSave(); return; } if (action === 'edit'){ const editable = commentsEl.querySelector(`.text[data-id="${id}"]`); if (editable){ editable.focus(); } return; } if (action === 'reply'){ const area = commentsEl.querySelector(`.reply-area[data-id="${id}"]`); if (area){ area.innerHTML = replyFormHTML(id); } return; } if (action === 'save-reply'){ const rid = Date.now(); const block = t.closest('.reply-area'); if (!block) return; const author = (block.querySelector('.reply-author').value || getSavedAuthor() || 'Anon').trim(); const text = (block.querySelector('.reply-text').value || '').trim(); if (!text) { alert('Please write a reply.'); return; } setSavedAuthor(author); c.replies.push({ rid, author, text, createdAt: new Date().toISOString() }); renderComments(); autoSave(); return; } if (action === 'cancel-reply'){ const block = t.closest('.reply-area'); if (block) block.innerHTML = ''; return; } if (action === 'edit-reply'){ const rid = +(t.getAttribute('data-rid') || 0); const editable = commentsEl.querySelector(`.reply-text[data-rid="${rid}"]`); if (editable){ editable.focus(); } return; } if (action === 'delete-reply'){ const rid = +(t.getAttribute('data-rid') || 0); if (!confirm('Delete this reply?')) return; c.replies = c.replies.filter(r => r.rid !== rid); renderComments(); autoSave(); return; } if (action === 'save-reply-edit'){ const rid = +(t.getAttribute('data-rid') || 0); const editable = commentsEl.querySelector(`.reply-text[data-rid="${rid}"]`); const reply = c.replies.find(r => r.rid === rid); if (editable && reply){ reply.text = editable.textContent || ''; } autoSave(); return; } if (action === 'submit'){ const editable = commentsEl.querySelector(`.text[data-id="${id}"]`); if (editable){ c.text = editable.textContent || ''; } autoSave(); return; } }); function replyFormHTML(id){ const namePrefill = getSavedAuthor(); return `
`; } commentsEl.addEventListener('input', (e)=>{ const t = e.target; if (!(t instanceof HTMLElement)) return; if (t.classList.contains('text')){ const id = +(t.getAttribute('data-id') || 0); const v = state.versions[state.currentVersion]; const c = v.comments.find(x=> x.id === id); if (c){ c.text = t.textContent || ''; autoSaveSoon(); } } if (t.classList.contains('reply-text')){ const id = +(t.getAttribute('data-id') || 0); const rid = +(t.getAttribute('data-rid') || 0); const v = state.versions[state.currentVersion]; const c = v.comments.find(x=> x.id === id); const reply = c && c.replies.find(r => r.rid === rid); if (reply){ reply.text = t.textContent || ''; autoSaveSoon(); } } }); filterSelect.addEventListener('change', ()=> renderComments()); // Render function addComment({ xPct, yPct, author, text, resolved }){ const v = state.versions[state.currentVersion]; const id = v.nextId++; const c = { id, xPct, yPct, resolved, author, text, createdAt: new Date().toISOString(), replies: [] }; v.comments.push(c); renderMarker(c); renderComments(); autoSave(); } function renderAll(){ const v = state.versions[state.currentVersion]; if (!v) return; img.onload = ()=>{ state.imgNatural.w = img.naturalWidth; state.imgNatural.h = img.naturalHeight; imageInner.style.width = state.imgNatural.w + 'px'; imageInner.style.height = state.imgNatural.h + 'px'; fitToViewport(); }; img.src = v.imageSrc; imageInner.querySelectorAll('.marker').forEach(m=> m.remove()); v.comments.forEach(renderMarker); stage.hidden = false; dropzone.hidden = true; renderComments(); updateExpiryUI(); } function renderMarker(c){ const existing = imageInner.querySelector(`[data-id="${c.id}"]`); if (existing) existing.remove(); const el = document.createElement('div'); el.className = `marker ${c.resolved ? 'resolved' : 'unresolved'}`; el.dataset.id = String(c.id); el.style.left = c.xPct + '%'; el.style.top = c.yPct + '%'; el.textContent = c.id; let tip; el.addEventListener('mouseenter', ()=>{ tip = document.createElement('div'); tip.className='tooltip'; tip.textContent = truncate(c.text, 80); el.appendChild(tip); }); el.addEventListener('mouseleave', ()=>{ if (tip) tip.remove(); }); el.addEventListener('click', ()=>{ imageInner.querySelectorAll('.marker').forEach(m=> m.classList.remove('active')); el.classList.add('active'); scrollCardIntoView(c.id); }); imageInner.appendChild(el); } function renderComments(){ const v = state.versions[state.currentVersion]; const filter = filterSelect.value; commentsEl.innerHTML = ''; const filteredComments = v.comments .filter(c => filter==='all' ? true : filter==='resolved' ? c.resolved : !c.resolved) .sort((a,b)=> new Date(a.createdAt) - new Date(b.createdAt)); filteredComments.forEach((c, index) => { const displayNumber = index + 1; const card = document.createElement('div'); card.className = `comment ${c.resolved ? 'resolved':''}`; card.innerHTML = `
#${displayNumber} ${sanitize(c.author || 'Anon')} ${formatTime(c.createdAt)} ${c.resolved ? 'Resolved' : 'Unresolved'}
${sanitize(c.text)}
${c.replies.map(r => `
${sanitize(r.author || 'Anon')} • ${formatTime(r.createdAt)}
${sanitize(r.text)}
`).join('')} `; commentsEl.appendChild(card); }); } function scrollCardIntoView(id){ const v = state.versions[state.currentVersion]; const filter = filterSelect.value; const filteredComments = v.comments .filter(c => filter==='all' ? true : filter==='resolved' ? c.resolved : !c.resolved) .sort((a,b)=> new Date(a.createdAt) - new Date(b.createdAt)); const commentIndex = filteredComments.findIndex(c => c.id === id); if (commentIndex >= 0) { const displayNumber = commentIndex + 1; const card = Array.from(commentsEl.children).find(el => el.querySelector && el.querySelector('.badge') && el.querySelector('.badge').textContent === `#${displayNumber}`); if (card) card.scrollIntoView({ behavior:'smooth', block:'center' }); } } // Share with debugging async function copyShareLink(){ let link = shareInput && shareInput.value ? shareInput.value : ''; console.log('📋 Current share link value:', link); if (!link){ await ensureProjectIdAndLink(); link = shareInput.value; console.log('📋 Generated new share link:', link); } // Double-check the format if (link.includes('vavlo-worker') || link.includes('bear-8c2')) { console.error('❌ Share link still contains worker URL! Fixing...'); const cleanLink = `${location.origin}${location.pathname}?shared=${state.projectId}`; shareInput.value = cleanLink; link = cleanLink; console.log('🔧 Fixed share link to:', link); } try{ await navigator.clipboard.writeText(link); alert('Share link copied: ' + link); } catch(e) { alert('Copy failed'); } } copyBtn.addEventListener('click', copyShareLink); // Save as downloadable HTML file - much simpler approach async function saveProject(){ try{ if (!state.projectId) { console.error('❌ Cannot save: No project ID'); return; } const payload = { projectId: state.projectId, createdAt: state.createdAt, expiresAt: state.expiresAt, accessCount: state.accessCount, versionOrder: state.versionOrder, currentVersion: state.currentVersion, versions: state.versions, updatedAt: new Date().toISOString() }; console.log('💾 Generating standalone HTML file...'); console.log('- Project ID:', state.projectId); // Generate the complete HTML file const htmlContent = generateProjectHTML(payload); // Save to localStorage as backup localStorage.setItem(`vavlo_project_${state.projectId}`, JSON.stringify(payload)); console.log('💾 Saved to localStorage as backup'); // Also store the HTML for download localStorage.setItem(`vavlo_html_${state.projectId}`, htmlContent); console.log('✅ Generated standalone HTML file successfully'); // Update share functionality to offer file download updateShareLinkForStaticFile(); } catch(err) { console.error('❌ HTML generation failed:', err); // Fallback: save to localStorage try { const payload = { projectId: state.projectId, createdAt: state.createdAt, expiresAt: state.expiresAt, accessCount: state.accessCount, versionOrder: state.versionOrder, currentVersion: state.currentVersion, versions: state.versions, updatedAt: new Date().toISOString() }; localStorage.setItem(`vavlo_project_${state.projectId}`, JSON.stringify(payload)); console.log('💾 Saved to localStorage as fallback'); } catch(localErr) { console.error('❌ All save methods failed:', localErr); } } } // Update share link to offer file download instead of API links function updateShareLinkForStaticFile() { if (shareInput && state.projectId) { // For now, show instructions for static file sharing const instructions = `Ready to share! Use "Download HTML" button to get a standalone file.`; shareInput.value = instructions; shareInput.style.fontSize = '12px'; console.log('🔗 Updated share instructions for static file approach'); } } // Add download functionality function downloadProjectHTML() { const htmlContent = localStorage.getItem(`vavlo_html_${state.projectId}`); if (!htmlContent) { alert('No project HTML available. Please save the project first.'); return; } const blob = new Blob([htmlContent], { type: 'text/html' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `vavlo-project-${state.projectId.substring(0, 8)}.html`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); console.log('📁 Downloaded project HTML file'); } // Comprehensive share link fix - run this every time anything changes function forceCleanShareLink() { if (shareInput && state.projectId) { const cleanUrl = `${location.origin}${location.pathname}?shared=${state.projectId}`; shareInput.value = cleanUrl; console.log('FORCED clean share URL:', cleanUrl); return cleanUrl; } return null; } // Run clean share link fix on all major events function autoSave(){ if (state.projectId) { forceCleanShareLink(); // Fix share link before saving saveProject(); } } // Load from JSONBin.io with fallback async function loadProjectFromShared(sharedVal){ try{ console.log('🔄 Loading shared project from JSONBin...'); console.log('- Input value:', sharedVal); let id = null; // Handle different URL formats if (sharedVal.includes('://')) { console.log('📋 Detected old URL format, extracting ID...'); try{ const u = new URL(sharedVal); const m = u.pathname.match(/\/([^\/]+)(?:\.json)?$/); id = m ? m[1] : null; }catch(urlErr){ id = sharedVal.split('/').pop().replace(/\.json$/, ''); } } else { console.log('📋 Detected clean format (just project ID)'); id = sharedVal.trim(); } console.log('✅ Final extracted ID:', id); if (!id || id.length < 10) { throw new Error(`Invalid project ID format: "${id}" (length: ${id ? id.length : 0})`); } // Try loading from JSONBin first let data = null; if (workerBase === 'jsonbin') { try { // Check if we have a bin ID stored locally for this project const binId = localStorage.getItem(`vavlo_bin_${id}`); if (binId) { console.log('📡 Attempting to fetch from JSONBin with bin ID:', binId); const response = await fetch(`${JSONBIN_CONFIG.apiUrl}/${binId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); console.log('📡 JSONBin response status:', response.status); if (response.ok) { const result = await response.json(); data = result.record || result; // JSONBin wraps data in 'record' property console.log('✅ Successfully loaded from JSONBin'); console.log('- Data keys:', Object.keys(data)); } else { console.log('❌ JSONBin failed:', response.status); } } else { console.log('⚠️ No bin ID found for project:', id); console.log('- This might be a project created before JSONBin integration'); } } catch(jsonbinErr) { console.log('❌ JSONBin request failed:', jsonbinErr.message); } } else { console.log('⚠️ JSONBin not available, skipping...'); } // Fallback to localStorage if JSONBin failed if (!data) { try { const localKey = `vavlo_project_${id}`; console.log('💾 Checking localStorage for key:', localKey); const localData = localStorage.getItem(localKey); if (localData) { data = JSON.parse(localData); console.log('✅ Successfully loaded from localStorage fallback'); console.log('- Data keys:', Object.keys(data)); } else { console.log('❌ No data found in localStorage either'); } } catch(localErr) { console.log('❌ localStorage fallback failed:', localErr.message); } } if (!data) { throw new Error('Project not found in JSONBin or localStorage. The project may not have been saved successfully, or the link is invalid.'); } console.log('📊 Processing loaded data...'); console.log('- Project ID:', data.projectId); console.log('- Created:', data.createdAt); console.log('- Current access count:', data.accessCount); state.projectId = data.projectId; state.createdAt = data.createdAt; state.expiresAt = data.expiresAt; // Increment access count and update display immediately state.accessCount = (data.accessCount || 0) + 1; console.log('📈 Incremented access count to:', state.accessCount); updateExpiryUI(); state.versionOrder = data.versionOrder || []; state.versions = data.versions || {}; state.currentVersion = data.currentVersion || (data.versionOrder && data.versionOrder[0]); console.log('🎨 Setting up UI...'); setVersions(state.versionOrder); if (state.currentVersion && state.versions[state.currentVersion]) { renderAll(); console.log('✅ Rendered successfully'); } else { console.log('⚠️ No valid version to render'); } // Update share input with clean format if (shareInput){ const cleanUrl = `${location.origin}${location.pathname}?shared=${state.projectId}`; shareInput.value = cleanUrl; console.log('🔗 Updated share link to clean format:', cleanUrl); } // Save the incremented access count back to database console.log('💾 Saving updated access count...'); await saveProject(); console.log('✅ Load process completed successfully'); }catch(err){ console.error('❌ Load process failed:', err); alert(`Could not load shared project: ${err.message}`); } } // Utils function formatTime(iso){ try{ const d = new Date(iso); return d.toLocaleString(); } catch{ return '' } } function truncate(str, n){ return (str||'').length > n ? (str||'').slice(0,n-1)+'…' : (str||''); } function sanitize(s){ return (s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); } function cryptoRandomId(len){ const arr = new Uint8Array(len); (crypto||window.crypto).getRandomValues(arr); const abc = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let out=''; for(const n of arr){ out += abc[n % abc.length]; } return out; } function updateExpiryUI(){ if (!state.expiresAt){ expiresText.textContent = '-'; return; } const now = Date.now(); const exp = new Date(state.expiresAt).getTime(); const days = Math.max(0, Math.ceil((exp - now) / (1000*60*60*24))); expiresText.textContent = `${new Date(state.expiresAt).toLocaleDateString()} (${days}d)`; accessText.textContent = String(state.accessCount || 0); } // Init with share link debugging (async function init(){ await resolveWorkerBase(); const shared = new URLSearchParams(location.search).get('shared'); if (shared){ state.sharedMode = true; uploadBtn.disabled = true; uploadBtn.title = 'Uploads disabled for shared viewers'; await loadProjectFromShared(shared); return; } await ensureProjectIdAndLink(); setVersions([]); updateExpiryUI(); // Force clean share link on every page load setTimeout(() => { if (shareInput && state.projectId) { const cleanUrl = `${location.origin}${location.pathname}?shared=${state.projectId}`; shareInput.value = cleanUrl; console.log('INIT: Forced clean share URL:', cleanUrl); } }, 100); })();