Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
3 lines (3 loc) · 11 KB
/
Copy pathapp.js
File metadata and controls
3 lines (3 loc) · 11 KB
1
2
3
const $=s=>document.querySelector(s),STORE='phoen.smartcard.v6',HISTORY='phoen.smartcard.scanned.v1',screens=['#welcome','#editor','#cardScreen'];let photoData='',activeCard=null,deferredPrompt=null,busy=false,back=false;function show(id){screens.forEach(s=>$(s).classList.toggle('hidden',s!==id));scrollTo(0,0)}function toast(v){$('#toast').textContent=v;$('#toast').classList.add('show');setTimeout(()=>$('#toast').classList.remove('show'),2200)}function url(v){return !v?'':/^https?:\/\//i.test(v)?v:'https://'+v}function esc(v=''){return v.replace(/\\/g,'\\\\').replace(/\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,')}function vcard(d){return`BEGIN:VCARD\nVERSION:3.0\nFN:${esc(d.name)}\nORG:${esc(d.company)}\nTITLE:${esc(d.role)}\nEMAIL:${d.email||''}\nTEL:${d.phone||''}\nURL:${url(d.website)}\nEND:VCARD`}function enc(v){let b=new TextEncoder().encode(JSON.stringify(v)),x='';b.forEach(n=>x+=String.fromCharCode(n));return btoa(x).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')}function dec(v){try{v=v.replace(/-/g,'+').replace(/_/g,'/');while(v.length%4)v+='=';return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(v),c=>c.charCodeAt(0))))}catch{return null}}function shared(){let q=new URLSearchParams(location.search);return q.get('shared')?dec(q.get('shared')):null}function shareURL(d){if(d.qrType==='url'&&d.shareUrl)return url(d.shareUrl);let u=new URL(location.href);u.search='';u.hash='';u.searchParams.set('shared',enc({name:d.name,role:d.role,company:d.company,email:d.email,phone:d.phone,website:d.website,photoUrl:d.photoUrl||'',qrType:'url'}));return u.toString()}
function historyItems(){try{return JSON.parse(localStorage.getItem(HISTORY)||'[]')}catch{return[]}}function contactKey(d){return[d.name,d.email,d.phone,d.website].filter(Boolean).join('|').toLowerCase()}function saveScanned(d){const key=contactKey(d);if(!key)return;let items=historyItems(),existing=items.find(x=>contactKey(x)===key),favorite=existing?.favorite===true;items=items.filter(x=>contactKey(x)!==key);items.unshift({name:d.name||'Shared contact',role:d.role||'',company:d.company||'',email:d.email||'',phone:d.phone||'',website:d.website||'',photoUrl:d.photoUrl||'',qrType:d.qrType||'url',scannedAt:existing?.scannedAt||Date.now(),favorite});localStorage.setItem(HISTORY,JSON.stringify(items.slice(0,20)));renderHistory()}function toggleFavorite(d){const key=contactKey(d);if(!key)return false;let items=historyItems(),item=items.find(x=>contactKey(x)===key);if(!item){saveScanned(d);items=historyItems();item=items.find(x=>contactKey(x)===key)}item.favorite=!item.favorite;localStorage.setItem(HISTORY,JSON.stringify(items));renderHistory();return item.favorite}function removeScanned(key){localStorage.setItem(HISTORY,JSON.stringify(historyItems().filter(x=>contactKey(x)!==key)));renderHistory()}function renderHistory(){const list=$('#historyList');if(!list)return;const items=historyItems().sort((a,b)=>(Number(b.favorite)-Number(a.favorite))||((b.scannedAt||0)-(a.scannedAt||0)));if(!items.length){list.innerHTML='<p class="historyEmpty">No scanned contacts yet.</p>';return}list.innerHTML='';items.forEach(item=>{const row=document.createElement('article');row.className='historyItem';const avatar=document.createElement('div');avatar.className='historyAvatar';if(item.photoUrl){const image=document.createElement('img');image.src=item.photoUrl;image.alt='';image.onerror=()=>{avatar.textContent=(item.name||'?').charAt(0).toUpperCase()};avatar.append(image)}else avatar.textContent=(item.name||'?').charAt(0).toUpperCase();const copy=document.createElement('button');copy.type='button';copy.className='historyOpen';copy.innerHTML='<b></b><small></small>';copy.querySelector('b').textContent=(item.favorite?'★ ':'')+(item.name||'Shared contact');copy.querySelector('small').textContent=[item.role,item.company].filter(Boolean).join(' · ')||'Scanned contact';copy.onclick=()=>{document.querySelector('#contactsDialog').close();render(item,true,false)};const del=document.createElement('button');del.type='button';del.className='historyDelete';del.setAttribute('aria-label','Remove '+(item.name||'contact')+' from history');del.textContent='×';del.onclick=()=>removeScanned(contactKey(item));row.append(avatar,copy,del);list.append(row)})}function drawQR(text){let box=$('#qr');box.innerHTML='';let q=new QRCode(0,QRErrorCorrectLevel.L);q.addData(text);q.make();let n=q.getModuleCount(),quiet=4,size=720,cell=size/(n+8),c=document.createElement('canvas');c.width=c.height=size;let x=c.getContext('2d');x.fillStyle='#fff';x.fillRect(0,0,size,size);x.fillStyle='#080808';for(let r=0;r<n;r++)for(let col=0;col<n;col++)if(q.isDark(r,col))x.fillRect(Math.floor((col+quiet)*cell),Math.floor((r+quiet)*cell),Math.ceil(cell),Math.ceil(cell));box.append(c);let m=$('#miniQr');m.width=m.height=180;m.getContext('2d').drawImage(c,0,0,180,180)}function syncPortraitQr(){let source=$('#miniQr'),target=$('#portraitQrCanvas');if(!source||!target)return;target.width=target.height=240;let x=target.getContext('2d');x.imageSmoothingEnabled=false;x.clearRect(0,0,240,240);x.drawImage(source,0,0,240,240)}function render(d,isShared=false,recordScan=true){activeCard=d;back=false;let card=$('#card');card.className='card show-front';[['cardName',d.name||'Your Name'],['qrName',d.name||'Contact'],['cardRole',d.role||'Independent professional'],['backRole',d.role||''],['cardCompany',d.company||''],['backCompany',d.company||''],['cardEmail',d.email||''],['backEmail',d.email||''],['cardPhone',d.phone||''],['backPhone',d.phone||''],['cardWeb',(d.website||'').replace(/^https?:\/\//,'')],['backWebsite',(d.website||'').replace(/^https?:\/\//,'')]].forEach(([id,v])=>$('#'+id).textContent=v);$('#mailAction').href='mailto:'+(d.email||'');$('#phoneAction').href='tel:'+(d.phone||'');$('#webAction').href=url(d.website)||'#';['#mailAction','#phoneAction','#webAction'].forEach((s,i)=>$(s).classList.toggle('hidden',![d.email,d.phone,d.website][i]));let im=$('#cardPhoto'),photoSrc=d.photoUrl||d.photo||'',portraitQr=$('#portraitQr'),portrait=$('.portrait');im.onerror=()=>{im.classList.add('hidden');portrait.classList.remove('has-photo');portraitQr.classList.remove('hidden')};if(photoSrc){im.src=photoSrc;im.classList.remove('hidden');portrait.classList.add('has-photo');portraitQr.classList.remove('hidden')}else{im.removeAttribute('src');im.classList.add('hidden');portrait.classList.remove('has-photo');portraitQr.classList.remove('hidden')}drawQR(d.qrType==='vcard'?vcard(d):shareURL(d));requestAnimationFrame(()=>syncPortraitQr());$('#qrHint').textContent=d.qrType==='vcard'?'Save this contact directly':'Open this shared Smartcard';document.body.classList.toggle('shared-mode',isShared);if(isShared&&recordScan)saveScanned(d);const saveBtn=$('#saveContactBtn'),savedContact=isShared&&historyItems().find(x=>contactKey(x)===contactKey(d));saveBtn.classList.toggle('hidden',!isShared);saveBtn.textContent=savedContact?.favorite?'★':'☆';saveBtn.setAttribute('aria-pressed',String(Boolean(savedContact?.favorite)));$('#sharedBanner').classList.toggle('hidden',!isShared);$('#sharedBanner').setAttribute('aria-hidden',String(!isShared));show('#cardScreen')}
function flip(want){if(busy||want===back)return;busy=true;let c=$('#card');c.classList.add('is-flipping');if(want){c.classList.add('flipped')}else{c.classList.remove('flipped')}back=want;setTimeout(()=>{c.classList.toggle('show-back',want);c.classList.toggle('show-front',!want);c.classList.remove('is-flipping');busy=false},840)}$('#card').onclick=e=>{if(!e.target.closest('a,button'))flip(!back)};$('#flipBack').onclick=e=>{e.stopPropagation();flip(false)};function data(){return{name:$('#name').value.trim(),role:$('#role').value.trim(),company:$('#company').value.trim(),email:$('#email').value.trim(),phone:$('#phone').value.trim(),website:$('#website').value.trim(),shareUrl:$('#shareUrl').value.trim(),photoUrl:$('#photoUrl').value.trim(),qrType:$('input[name=qrType]:checked').value,photo:photoData}}function fill(d={}){['name','role','company','email','phone','website','shareUrl','photoUrl'].forEach(k=>$('#'+k).value=d[k]||'');photoData=d.photo||'';if(d.qrType)$(`input[name=qrType][value="${d.qrType}"]`).checked=true;$('#photoPreview').innerHTML=d.photo?`<img src="${d.photo}">`:'Add profile image'}$('#startBtn').onclick=()=>{fill(JSON.parse(localStorage.getItem(STORE)||'{}'));show('#editor')};$('#editorBack').onclick=()=>show(localStorage.getItem(STORE)?'#cardScreen':'#welcome');$('#form').onsubmit=e=>{e.preventDefault();let d=data();localStorage.setItem(STORE,JSON.stringify(d));history.replaceState({},'',location.pathname);render(d);toast('Saved locally')};$('#photo').onchange=e=>{let f=e.target.files[0];if(!f)return;let r=new FileReader(),im=new Image();r.onload=()=>{im.onload=()=>{let c=document.createElement('canvas'),s=Math.min(1,700/Math.max(im.width,im.height));c.width=im.width*s;c.height=im.height*s;c.getContext('2d').drawImage(im,0,0,c.width,c.height);photoData=c.toDataURL('image/jpeg',.82);$('#photoPreview').innerHTML=`<img src="${photoData}">`};im.src=r.result};r.readAsDataURL(f)};$('#shareBtn').onclick=async()=>{let u=shareURL(activeCard);if(navigator.share)await navigator.share({title:activeCard.name+' Smartcard',url:u});else{await navigator.clipboard.writeText(u);toast('Link copied')}};$('#vcfBtn').onclick=()=>{let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([vcard(activeCard)],{type:'text/vcard'}));a.download=(activeCard.name||'contact')+'.vcf';a.click()};$('#saveContactBtn').onclick=()=>{if(!activeCard)return;const favorite=toggleFavorite(activeCard);$('#saveContactBtn').textContent=favorite?'★':'☆';$('#saveContactBtn').setAttribute('aria-pressed',String(favorite));toast(favorite?'Contact added to favorites':'Contact removed from favorites')};let contactsDlg=$('#contactsDialog');$('#contactsBtn').onclick=()=>{renderHistory();contactsDlg.showModal()};$('#closeContacts').onclick=()=>contactsDlg.close();let dlg=$('#settings');$('#settingsBtn').onclick=()=>dlg.showModal();$('#closeSettings').onclick=()=>dlg.close();$('#clearHistoryBtn').onclick=()=>{localStorage.removeItem(HISTORY);renderHistory();toast('Scanned contacts cleared')};$('#editBtn').onclick=()=>{dlg.close();fill(JSON.parse(localStorage.getItem(STORE)||'{}'));show('#editor')};$('#restartBtn').onclick=()=>{dlg.close();show('#welcome')};$('#resetBtn').onclick=()=>{localStorage.removeItem(STORE);dlg.close();show('#welcome')};addEventListener('beforeinstallprompt',e=>{e.preventDefault();deferredPrompt=e;$('#installIntro').classList.remove('hidden')});async function install(){if(deferredPrompt){deferredPrompt.prompt();const result=await deferredPrompt.userChoice;if(result.outcome==='accepted')toast('Smartcard installed');deferredPrompt=null}else{const isiOS=/iphone|ipad|ipod/i.test(navigator.userAgent);toast(isiOS?'Use Share, then Add to Home Screen':'Use your browser menu, then Install app')}}$('#installBtn').onclick=install;$('#installIntro').onclick=install;if('serviceWorker'in navigator)addEventListener('load',()=>navigator.serviceWorker.register('./sw.js'));let sd=shared();sd?render(sd,true):localStorage.getItem(STORE)?render(JSON.parse(localStorage.getItem(STORE))):show('#welcome');