Files
carlitosbondandClaude Sonnet 5 6ee4022d21 Add fotos.carloselugo.com photo-upload service
Event photo upload app with OAuth2 (Google Drive), Caddy route, and
env-var-driven compose config. Refresh token persists to token.json
so re-auth isn't needed after container restarts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:24:00 +00:00

392 lines
11 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Fotos del Evento</title>
<!--
============================================================
LANDING PAGE "CERO FRICCIÓN" - SUBIDA DE FOTOS DEL EVENTO
============================================================
- Sin login, sin registro, sin email.
- Un solo botón grande: "Añadir fotos".
- Subida automática en segundo plano vía fetch() + FormData.
- Feedback visual claro en cada estado.
============================================================
-->
<style>
:root {
/* Paleta tomada del escudo "War Hawks": azul marino + dorado */
--navy: #0d1b3e;
--navy-dark: #081127;
--gold: #d4af5f;
--gold-light: #e8cd8f;
--bg: #f5f2e9;
--text: #0d1b3e;
--muted: #5c6172;
--success: #1e8e3e;
--error: #d93025;
--radius: 18px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 24px;
background:
radial-gradient(circle at 50% 0%, rgba(212, 175, 95, 0.10), transparent 55%),
var(--bg);
}
.container {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.card {
width: 100%;
background: #fff;
border-radius: 22px;
padding: 36px 22px 44px;
box-shadow: 0 10px 30px rgba(13, 27, 62, 0.15);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.card img.logo {
width: 260px;
height: auto;
margin-top: -4px;
}
p.subtitle {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.4;
}
/* Input real de archivo, oculto. Usamos un <label> como botón visual
para poder estilizarlo libremente en iOS/Android. */
#fileInput {
display: none;
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 20px 18px;
background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 100%);
color: var(--navy-dark);
font-size: 1.1rem;
font-weight: 800;
border: none;
border-radius: var(--radius);
cursor: pointer;
user-select: none;
transition: transform 0.12s ease, filter 0.2s ease, opacity 0.2s ease;
box-shadow: 0 8px 22px rgba(212, 175, 95, 0.35);
}
.upload-btn:active {
transform: scale(0.97);
filter: brightness(0.95);
}
.upload-btn.disabled {
opacity: 0.6;
pointer-events: none;
}
/* ---- Estado / feedback ---- */
#status {
min-height: 28px;
font-size: 0.95rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
#status.uploading { color: var(--muted); }
#status.success { color: var(--success); }
#status.error { color: var(--error); }
.spinner {
width: 16px;
height: 16px;
border: 2.5px solid rgba(13, 27, 62, 0.15);
border-top-color: var(--navy);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Barra de progreso simple (número de archivos subidos / total) */
.progress-wrap {
width: 100%;
height: 8px;
background: rgba(13, 27, 62, 0.08);
border-radius: 999px;
overflow: hidden;
display: none;
}
.progress-wrap.active { display: block; }
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--navy), var(--gold));
transition: width 0.25s ease;
}
footer {
margin-top: 8px;
margin-bottom: 24px;
font-size: 0.75rem;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Preview miniaturas opcional (pequeñas, solo confirmación visual) */
.thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
max-width: 100%;
}
.thumbs img {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 8px;
opacity: 0.9;
border: 2px solid var(--gold-light);
}
.disabled-msg {
background: rgba(217, 48, 37, 0.08);
color: var(--error);
padding: 16px;
border-radius: var(--radius);
font-size: 0.95rem;
font-weight: 600;
line-height: 1.4;
width: 100%;
}
/* Ajustes finos para pantallas muy pequeñas (iPhone SE y similares) */
@media (max-width: 360px) {
.card { padding: 28px 16px 34px; border-radius: 18px; }
.card img.logo { width: 220px; }
}
/* Pantallas grandes: un poco más de aire */
@media (min-width: 600px) {
.container { max-width: 460px; }
.card img.logo { width: 290px; }
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<img class="logo" src="/assets/logo.png" alt="War Hawks - Reencuentro de Clase Graduada - Sube tus fotos">
<p class="subtitle">Selecciona las fotos o videos de tu galería.<br>Se suben automáticamente, sin necesidad de registrarte.</p>
<!-- Label estilizado como botón, dispara el input real -->
<label for="fileInput" id="uploadBtn" class="upload-btn">
➕ Añadir fotos
</label>
<input
type="file"
id="fileInput"
accept="image/*,video/*"
multiple
>
<!-- Mensaje mostrado cuando el admin desactiva las subidas -->
<div id="disabledMsg" class="disabled-msg" style="display:none;">
🚫 La subida de fotos está desactivada por el momento.<br>Vuelve a intentarlo más tarde.
</div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar" id="progressBar"></div>
</div>
<div id="status"></div>
<div class="thumbs" id="thumbs"></div>
</div>
</div>
<footer>War Hawks · Reencuentro de Clase Graduada</footer>
<script>
(function () {
'use strict';
// ------------------------------------------------------------------
// CONFIGURACIÓN
// ------------------------------------------------------------------
// URL del backend. Si el frontend y backend se sirven desde el mismo
// origen (ej. Express sirviendo /public), puede dejarse como ruta
// relativa '/upload'. Si se despliega por separado, poner la URL
// completa, ej: 'https://mi-api.onrender.com/upload'
const UPLOAD_ENDPOINT = '/upload';
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const statusEl = document.getElementById('status');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const thumbsEl = document.getElementById('thumbs');
const disabledMsg = document.getElementById('disabledMsg');
// ------------------------------------------------------------------
// Verifica al cargar si las subidas están activadas (panel /admin).
// Si el admin las desactivó, ocultamos el botón y mostramos el aviso.
// ------------------------------------------------------------------
async function checkUploadStatus() {
try {
const res = await fetch('/status');
const data = await res.json();
if (!data.enabled) {
uploadBtn.style.display = 'none';
disabledMsg.style.display = 'block';
}
} catch (err) {
// Si /status falla, dejamos el botón visible por defecto
// para no bloquear al usuario por un error de red transitorio.
console.warn('No se pudo verificar el estado de subidas:', err);
}
}
checkUploadStatus();
function setStatus(type, html) {
statusEl.className = type;
statusEl.innerHTML = html;
}
function resetStatusAfterDelay(ms) {
setTimeout(() => {
setStatus('', '');
progressWrap.classList.remove('active');
progressBar.style.width = '0%';
thumbsEl.innerHTML = '';
}, ms);
}
function showThumbs(files) {
thumbsEl.innerHTML = '';
Array.from(files).slice(0, 8).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.onload = () => URL.revokeObjectURL(img.src);
thumbsEl.appendChild(img);
});
}
fileInput.addEventListener('change', async () => {
const files = fileInput.files;
if (!files || files.length === 0) return;
showThumbs(files);
uploadBtn.classList.add('disabled');
progressWrap.classList.add('active');
progressBar.style.width = '5%';
setStatus('uploading', '<span class="spinner"></span> Subiendo ' + files.length + ' archivo(s)...');
const formData = new FormData();
// El backend espera el campo 'photos' (array, múltiples archivos)
Array.from(files).forEach((file) => {
formData.append('photos', file);
});
try {
// Usamos XMLHttpRequest en vez de fetch puro para poder mostrar
// progreso real de subida (fetch no expone upload progress nativo).
await uploadWithProgress(formData);
progressBar.style.width = '100%';
setStatus('success', '✅ ¡Fotos subidas con éxito!');
resetStatusAfterDelay(4000);
} catch (err) {
console.error('Error subiendo archivos:', err);
setStatus('error', '❌ Error al subir. Intenta de nuevo.');
progressWrap.classList.remove('active');
} finally {
uploadBtn.classList.remove('disabled');
fileInput.value = ''; // permite volver a seleccionar los mismos archivos
}
});
function uploadWithProgress(formData) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', UPLOAD_ENDPOINT, true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const pct = Math.max(10, Math.round((event.loaded / event.total) * 100));
progressBar.style.width = pct + '%';
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else if (xhr.status === 403) {
// Las subidas fueron desactivadas por el admin justo ahora
uploadBtn.style.display = 'none';
disabledMsg.style.display = 'block';
reject(new Error('Subidas desactivadas'));
} else {
reject(new Error('HTTP ' + xhr.status + ': ' + xhr.responseText));
}
};
xhr.onerror = () => reject(new Error('Error de red'));
xhr.send(formData);
});
}
})();
</script>
</body>
</html>