'use strict'; /* Fotos de familia — cliente. Habla con /api (Cloudflare Pages Functions). */ /* ───────────────── Estado y sesión ───────────────── */ var state = { role: null, token: null, view: 'recientes', album: null, favorites: new Set(), selectMode: false, selected: new Set(), viewerList: [], viewerIndex: 0 }; var PHOTOS = []; var ALBUM_NAMES = []; function loadSession(){ var t = localStorage.getItem('ff_token') || sessionStorage.getItem('ff_token'); var r = localStorage.getItem('ff_role') || sessionStorage.getItem('ff_role'); if(t && r){ state.token = t; state.role = r; } try{ state.favorites = new Set(JSON.parse(localStorage.getItem('ff_favs') || '[]')); }catch(e){} } function saveSession(remember){ var store = remember ? localStorage : sessionStorage; store.setItem('ff_token', state.token); store.setItem('ff_role', state.role); } function clearSession(){ ['ff_token','ff_role'].forEach(function(k){ localStorage.removeItem(k); sessionStorage.removeItem(k); }); state.token = null; state.role = null; } function saveFavs(){ localStorage.setItem('ff_favs', JSON.stringify(Array.from(state.favorites))); } /* ───────────────── API ───────────────── */ function api(path, opts){ opts = opts || {}; opts.headers = Object.assign({}, opts.headers, { authorization: 'Bearer ' + state.token }); return fetch('/api/' + path, opts).then(function(r){ if(r.status === 401){ doLogout(); throw new Error('Sesión caducada'); } return r.json().then(function(j){ if(!r.ok) throw new Error(j.error || 'Error de la aplicación'); return j; }); }); } function mediaUrl(kind, id, extra){ return '/api/' + kind + '/' + id + '?t=' + encodeURIComponent(state.token) + (extra || ''); } function applyIndex(index){ ALBUM_NAMES = index.albums || []; PHOTOS = (index.items || []).map(function(m){ return { id: m.id, name: m.name, album: m.album, video: m.video, date: new Date(m.date), dur: m.dur ? fmtDur(m.dur) : null, src: mediaUrl('thumb', m.id, '&s=480'), big: mediaUrl('thumb', m.id, '&s=1600') }; }); } function fmtDur(s){ var m = Math.floor(s/60); return m + ':' + String(s % 60).padStart(2, '0'); } /* ───────────────── Utilidades ───────────────── */ var MESES = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']; function $(s){ return document.querySelector(s); } function dayLabel(date){ var hoy = new Date(); hoy.setHours(0,0,0,0); var dd = new Date(date); dd.setHours(0,0,0,0); var diff = Math.round((hoy - dd)/864e5); if(diff === 0) return 'Hoy'; if(diff === 1) return 'Ayer'; return dd.getDate() + ' de ' + MESES[dd.getMonth()] + (dd.getFullYear() !== hoy.getFullYear() ? ' de ' + dd.getFullYear() : ''); } var toastTimer = null; function toast(msg){ var t = $('#toast'); t.textContent = msg; t.classList.remove('hidden'); if(toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function(){ t.classList.add('hidden'); }, 3000); } function svgHeart(filled){ return ''; } /* ───────────────── Cuadrículas ───────────────── */ function photoTile(p){ var b = document.createElement('button'); b.className = 'ph' + (state.selected.has(p.id) ? ' selected' : ''); b.setAttribute('aria-label', (p.video?'Vídeo':'Foto') + ' del ' + dayLabel(p.date)); var inner = ''; if(state.favorites.has(p.id)) inner += ''; if(p.video) inner += ''+(p.dur||'')+''; inner += ''; b.innerHTML = inner; b.addEventListener('click', function(){ onTileClick(p); }); return b; } function renderGroupedGrid(container, photos){ var lastLabel = null, grid = null; photos.forEach(function(p){ var lbl = dayLabel(p.date); if(lbl !== lastLabel){ var h = document.createElement('div'); h.className='day-label'; h.textContent = lbl; container.appendChild(h); grid = document.createElement('div'); grid.className='grid'; container.appendChild(grid); lastLabel = lbl; } grid.appendChild(photoTile(p)); }); } /* ───────────────── Vistas ───────────────── */ function setTitle(t, hint){ $('#page-title').textContent = t; $('#page-hint').textContent = hint || ''; } function viewRecientes(c){ var hoyCount = PHOTOS.filter(function(p){ return dayLabel(p.date)==='Hoy'; }).length; setTitle('Hola, familia', hoyCount ? hoyCount + ' fotos nuevas hoy' : 'Al día con el álbum'); if(!PHOTOS.length){ var e = document.createElement('div'); e.className='empty'; e.innerHTML = '

El álbum está vacío.

Toca + para añadir las primeras fotos.

'; c.appendChild(e); return; } renderGroupedGrid(c, PHOTOS); } function viewAlbumes(c){ if(state.album){ var fotos = PHOTOS.filter(function(p){ return p.album === state.album; }); setTitle(state.album, fotos.length + ' elementos'); var back = document.createElement('button'); back.className='back-row'; back.innerHTML = ' Todos los álbumes'; back.addEventListener('click', function(){ state.album = null; render(); }); c.appendChild(back); renderGroupedGrid(c, fotos); return; } setTitle('Álbumes', 'Tal como están organizados en Drive'); var wrap = document.createElement('div'); wrap.className='album-grid'; ALBUM_NAMES.forEach(function(name){ var fotos = PHOTOS.filter(function(p){ return p.album===name; }); if(!fotos.length) return; var card = document.createElement('button'); card.className='album-card'; card.innerHTML = '
'+fotos.length+' elementos
'; card.querySelector('.name').textContent = name; card.addEventListener('click', function(){ state.album = name; render(); }); wrap.appendChild(card); }); c.appendChild(wrap); } function viewVideos(c){ var vids = PHOTOS.filter(function(p){ return p.video; }); setTitle('Vídeos', vids.length + ' vídeos · solo cargan al pulsar play'); renderGroupedGrid(c, vids); } function viewFavoritos(c){ var favs = PHOTOS.filter(function(p){ return state.favorites.has(p.id); }); setTitle('Favoritos', favs.length ? favs.length + ' marcados en este dispositivo' : ''); if(!favs.length){ var e = document.createElement('div'); e.className='empty'; e.innerHTML = '

Aún no hay favoritos.

Abre una foto y toca el corazón
para guardarla aquí.

'; c.appendChild(e); return; } renderGroupedGrid(c, favs); } function render(){ var c = $('#content'); c.innerHTML=''; var views = { recientes: viewRecientes, albumes: viewAlbumes, videos: viewVideos, favoritos: viewFavoritos }; views[state.view](c); document.querySelectorAll('#mainnav button').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-view')===state.view); }); $('#btn-select').classList.toggle('active', state.selectMode); } /* ───────────────── Interacción ───────────────── */ function currentList(){ if(state.view==='videos') return PHOTOS.filter(function(p){ return p.video; }); if(state.view==='favoritos') return PHOTOS.filter(function(p){ return state.favorites.has(p.id); }); if(state.view==='albumes' && state.album) return PHOTOS.filter(function(p){ return p.album===state.album; }); return PHOTOS; } function onTileClick(p){ if(state.selectMode){ if(state.selected.has(p.id)) state.selected.delete(p.id); else state.selected.add(p.id); updateSelBar(); render(); return; } openViewer(currentList(), p); } /* ───────────────── Visor ───────────────── */ function openViewer(list, photo){ state.viewerList = list.slice(); state.viewerIndex = state.viewerList.indexOf(photo); $('#viewer').classList.remove('hidden'); document.body.style.overflow='hidden'; updateViewer(); } function closeViewer(){ $('#viewer').classList.add('hidden'); var v = $('#v-video'); v.pause(); v.removeAttribute('src'); v.load(); document.body.style.overflow=''; render(); } function updateViewer(){ var p = state.viewerList[state.viewerIndex]; if(!p) return closeViewer(); var img = $('#v-img'), vid = $('#v-video'); vid.pause(); vid.removeAttribute('src'); vid.load(); if(p.video){ img.classList.add('hidden'); vid.classList.remove('hidden'); vid.poster = p.big; vid.src = mediaUrl('play', p.id); $('#v-playwrap').classList.add('hidden'); } else { vid.classList.add('hidden'); img.classList.remove('hidden'); img.src = p.big; img.alt = 'Foto del ' + dayLabel(p.date); $('#v-playwrap').classList.add('hidden'); } $('#v-date').textContent = dayLabel(p.date); $('#v-album').textContent = p.album; var on = state.favorites.has(p.id); $('#v-fav').classList.toggle('fav-on', on); $('#v-fav').setAttribute('aria-pressed', String(on)); $('#v-fav-ic').setAttribute('fill', on ? 'currentColor' : 'none'); $('#v-fav-txt').textContent = on ? 'Favorito ✓' : 'Favorito'; $('#v-prev').style.visibility = state.viewerIndex>0 ? 'visible':'hidden'; $('#v-next').style.visibility = state.viewerIndex=0 && n= state.viewerList.length) state.viewerIndex = state.viewerList.length - 1; updateViewer(); }); }); var selDeleteArmed = false; $('#sel-delete').addEventListener('click', function(){ if(state.role !== 'admin') return; var n = state.selected.size; if(!n){ toast('No hay nada seleccionado todavía'); return; } if(!selDeleteArmed){ selDeleteArmed = true; $('#sel-delete').classList.add('arm'); $('#sel-delete').textContent = '¿Eliminar ' + n + '?'; return; } apiDelete(Array.from(state.selected), function(res){ toast(res.borradas + (res.borradas===1?' elemento eliminado':' elementos eliminados')); exitSelect(); }); }); /* ───────────────── Descarga múltiple ───────────────── */ $('#sel-download').addEventListener('click', function(){ var ids = Array.from(state.selected); if(!ids.length){ toast('No hay nada seleccionado todavía'); return; } toast('Descargando ' + ids.length + ' originales…'); ids.forEach(function(id, i){ setTimeout(function(){ var p = PHOTOS.find(function(x){ return x.id===id; }); var a = document.createElement('a'); a.href = mediaUrl('file', id); a.download = (p && p.name) || 'foto'; document.body.appendChild(a); a.click(); a.remove(); }, i * 600); }); exitSelect(); }); /* ───────────────── Login y sesión ───────────────── */ function showApp(){ $('#login').classList.add('hidden'); $('#app').classList.remove('hidden'); setTitle('Hola, familia', 'Cargando el álbum…'); api('index').then(function(index){ applyIndex(index); render(); }).catch(function(e){ toast(e.message); }); } function doLogin(){ var v = $('#key-input').value.trim(); if(!v){ $('#login-error').textContent = 'Escribe la clave de la familia.'; return; } var btn = $('#btn-login'); btn.disabled = true; btn.textContent = 'Entrando…'; fetch('/api/login', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ key: v }) }) .then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); }) .then(function(res){ if(!res.ok){ $('#login-error').textContent = 'Esa clave no es. Pregunta a quien lleva el álbum.'; return; } state.token = res.j.token; state.role = res.j.role; saveSession($('#remember').checked); $('#login-error').textContent=''; showApp(); toast(state.role==='admin' ? 'Hola de nuevo, administrador' : 'Clave guardada en este dispositivo'); }) .catch(function(){ $('#login-error').textContent = 'No hay conexión con la aplicación. Inténtalo de nuevo.'; }) .finally(function(){ btn.disabled = false; btn.textContent = 'Entrar'; }); } function doLogout(){ clearSession(); state.album = null; state.view='recientes'; state.selectMode = false; state.selected.clear(); $('#settings').classList.add('hidden'); $('#viewer').classList.add('hidden'); $('#selbar').classList.add('hidden'); $('#app').classList.add('hidden'); $('#login').classList.remove('hidden'); $('#key-input').value=''; document.body.style.overflow=''; } /* ───────────────── Eventos generales ───────────────── */ $('#btn-login').addEventListener('click', doLogin); $('#key-input').addEventListener('keydown', function(e){ if(e.key==='Enter') doLogin(); }); document.querySelectorAll('#mainnav button').forEach(function(b){ b.addEventListener('click', function(){ state.view = b.getAttribute('data-view'); state.album = null; exitSelect(); }); }); $('#btn-select').addEventListener('click', function(){ state.selectMode = !state.selectMode; if(!state.selectMode) state.selected.clear(); updateSelBar(); render(); if(state.selectMode) toast('Toca las fotos que quieras descargar juntas'); }); $('#sel-cancel').addEventListener('click', exitSelect); $('#v-close').addEventListener('click', closeViewer); $('#v-prev').addEventListener('click', function(){ viewerStep(-1); }); $('#v-next').addEventListener('click', function(){ viewerStep(1); }); $('#v-fav').addEventListener('click', toggleFav); $('#v-download').addEventListener('click', downloadCurrent); document.addEventListener('keydown', function(e){ if($('#viewer').classList.contains('hidden')) return; if(e.key==='ArrowLeft') viewerStep(-1); if(e.key==='ArrowRight') viewerStep(1); if(e.key==='Escape') closeViewer(); }); $('#btn-settings').addEventListener('click', function(){ $('#row-key').classList.toggle('hidden', state.role !== 'admin'); $('#row-key-locked').classList.toggle('hidden', state.role === 'admin'); $('#settings').classList.remove('hidden'); }); $('#settings-close').addEventListener('click', function(){ $('#settings').classList.add('hidden'); }); $('#settings').addEventListener('click', function(e){ if(e.target === $('#settings')) $('#settings').classList.add('hidden'); }); $('#save-key').addEventListener('click', function(){ var v = $('#new-key').value.trim(); if(v.length < 4){ toast('La clave necesita al menos 4 caracteres'); return; } api('change-key', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ newKey: v }) }) .then(function(){ $('#new-key').value=''; toast('Clave cambiada. Avisa a la familia de la nueva.'); }) .catch(function(e){ toast(e.message); }); }); $('#clear-favs').addEventListener('click', function(){ state.favorites.clear(); saveFavs(); toast('Favoritos vaciados en este dispositivo'); render(); }); $('#install-app').addEventListener('click', function(){ toast('Usa el menú del navegador: «Añadir a pantalla de inicio» o «Instalar aplicación»'); }); $('#logout').addEventListener('click', doLogout); /* ───────────────── Arranque ───────────────── */ loadSession(); if(state.token){ showApp(); }