Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 152 additions & 6 deletions frontend/src/player/PlayerApp.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { advancePlayer, fetchPlayerGame, fetchPublicConfig, fetchTeamStatus, sendHeartbeat } from '../shared/api'
import type { PlayerGamePayload, PlayerGpsStatus, PlayerStage, TeamProfileLiveStatus } from '../types/player'
import { advancePlayer, fetchFieldProofs, fetchPlayerGame, fetchPublicConfig, fetchTeamStatus, sendHeartbeat, uploadFieldProof } from '../shared/api'
import type { FieldProof, PlayerGamePayload, PlayerGpsStatus, PlayerStage, TeamProfileLiveStatus } from '../types/player'
import { PlayerShell } from './components/PlayerShell'
import { PlayerHud } from './components/PlayerHud'
import { QuickProofPanel } from './components/QuickProofPanel'
Expand Down Expand Up @@ -46,6 +46,46 @@ function getUserFromUrl(): string {
return params.get('user') || 'PLAYER 1'
}

function fileToFieldPhotoDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('El archivo debe ser una imagen.'))
return
}

const reader = new FileReader()
reader.onerror = () => reject(new Error('No se pudo leer la imagen.'))
reader.onload = () => {
const image = new Image()
image.onerror = () => reject(new Error('No se pudo procesar la imagen.'))

image.onload = () => {
const maxSide = 1280
const scale = Math.min(1, maxSide / Math.max(image.width, image.height))
const width = Math.max(1, Math.round(image.width * scale))
const height = Math.max(1, Math.round(image.height * scale))

const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height

const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Canvas no disponible.'))
return
}

ctx.drawImage(image, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.82))
}

image.src = String(reader.result || '')
}

reader.readAsDataURL(file)
})
}

function isPhysicalQrStage(stage: PlayerStage | null): boolean {
if (!stage || typeof stage !== 'object') return false

Expand Down Expand Up @@ -87,7 +127,10 @@ export default function PlayerApp() {
const [browserGpsPosition, setBrowserGpsPosition] = useState<{ lat: number; lon: number } | null>(null)
const [browserGpsStatus, setBrowserGpsStatus] = useState<PlayerGpsStatus>('unavailable')
const [quickQrOpenSignal, setQuickQrOpenSignal] = useState(0)
const [fieldProofs, setFieldProofs] = useState<FieldProof[]>([])
const [fieldPhotoUploading, setFieldPhotoUploading] = useState(false)

const fieldPhotoInputRef = useRef<HTMLInputElement | null>(null)
const noticeTimerRef = useRef<number | null>(null)
const overlayTimerRef = useRef<number | null>(null)
const gpsWatchRef = useRef<number | null>(null)
Expand Down Expand Up @@ -186,6 +229,32 @@ export default function PlayerApp() {
}
}, [user])

useEffect(() => {
let cancelled = false
let intervalId: number | null = null

async function loadFieldProofs() {
try {
const payload = await fetchFieldProofs(user)
if (!cancelled) {
setFieldProofs(Array.isArray(payload.proofs) ? payload.proofs : [])
}
} catch {
// Field photos are online-only for now; keep the map usable if this fails.
}
}

void loadFieldProofs()
intervalId = window.setInterval(loadFieldProofs, 15000)

return () => {
cancelled = true
if (intervalId !== null) {
window.clearInterval(intervalId)
}
}
}, [user])

useEffect(() => {
if (state.status !== 'ready') return

Expand Down Expand Up @@ -408,6 +477,72 @@ export default function PlayerApp() {
return nextPayload
}

async function refreshFieldProofs() {
const nextProofs = await fetchFieldProofs(user)
setFieldProofs(Array.isArray(nextProofs.proofs) ? nextProofs.proofs : [])
return nextProofs
}

function handleOpenFieldPhotoCapture() {
if (fieldPhotoUploading) {
showNotice('Subiendo foto…', 'info')
return
}

if (!playerPosition) {
showNotice('Activa GPS o usa modo debug para guardar una foto en el mapa.', 'warn')
vibrate(8)
return
}

fieldPhotoInputRef.current?.click()
}

async function handleFieldPhotoSelected(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0]
event.currentTarget.value = ''

if (!file) return

if (!playerPosition) {
showNotice('No hay posición para guardar la foto.', 'warn')
return
}

try {
setFieldPhotoUploading(true)
showNotice('Preparando foto…', 'info')

const imageDataUrl = await fileToFieldPhotoDataUrl(file)
const note = window.prompt('Nota opcional para esta foto') || ''

const saved = await uploadFieldProof({
user: payload.user,
image_data_url: imageDataUrl,
lat: playerPosition.lat,
lon: playerPosition.lon,
note,
stage_id: currentStage?.id ? String(currentStage.id) : undefined,
stage_title: currentStage?.title || undefined,
})

setFieldProofs((current) => [
saved.proof,
...current.filter((item) => item.id !== saved.proof.id),
])

void refreshFieldProofs().catch(() => {})
showNotice('Foto compartida en el mapa.', 'success')
vibrate([10, 16, 10])
} catch (error) {
showNotice(error instanceof Error ? error.message : 'No se pudo subir la foto.', 'warn')
vibrate(8)
} finally {
setFieldPhotoUploading(false)
}
}


function togglePanel(panel: Exclude<PlayerPanel, null>) {
setToolsOpen(false)
setTeamOpen(false)
Expand Down Expand Up @@ -854,6 +989,7 @@ return
focusRequest={focusRequest}
nodeState={interactionOpen ? 'engaging' : runtime.canEnter ? 'ready' : 'locked'}
otherPlayers={teamMapMarkers}
fieldProofs={fieldProofs}
selfLabel={payload.display_name || payload.user || 'YO'}
selfProfile={{
...(payload.profile || {}),
Expand Down Expand Up @@ -883,6 +1019,15 @@ return
<ToastNotice notice={uiNotice} />
</div>

<input
ref={fieldPhotoInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
capture="environment"
style={{ display: 'none' }}
onChange={(event) => void handleFieldPhotoSelected(event)}
/>

{activePanel !== 'details' && !toolsOpen && !teamOpen && !overlayState ? (
<div style={getMapQuickControlsStyle(isPhone)}>
<QuickProofPanel
Expand All @@ -896,15 +1041,16 @@ return
<button
type="button"
style={mapRouteToggleInlineButton}
disabled={fieldPhotoUploading}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
showNotice('📸 Prueba rápida: fotos de mapa y carrusel llegarán en el siguiente PR.', 'info')
vibrate(8)
handleOpenFieldPhotoCapture()
}}
aria-label="Prueba rápida"
aria-label="Foto de campo"
title="Foto de campo"
>
<span aria-hidden="true" style={mapQuickIcon}>📷</span>
<span aria-hidden="true" style={mapQuickIcon}>{fieldPhotoUploading ? '⏳' : '📷'}</span>
</button>

<button
Expand Down
108 changes: 107 additions & 1 deletion frontend/src/player/components/MapSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { PlayerGpsStatus, PlayerProfile, PlayerStage, TeamProfileLiveStatus } from '../../types/player'
import type { FieldProof, PlayerGpsStatus, PlayerProfile, PlayerStage, TeamProfileLiveStatus } from '../../types/player'
import { getPlayerAvatarInitials, getPlayerAvatarUrl, getPlayerColor } from '../../shared/playerIdentity'

type FocusRequest =
Expand Down Expand Up @@ -58,6 +58,7 @@ type MapSurfaceProps = {
focusRequest?: FocusRequest
nodeState?: NodeVisualState
otherPlayers?: TeamProfileLiveStatus[]
fieldProofs?: FieldProof[]
selfLabel?: string
selfProfile?: Partial<PlayerProfile & TeamProfileLiveStatus>
onDebugSetPosition?: (position: { lat: number; lon: number }) => void
Expand Down Expand Up @@ -222,6 +223,58 @@ function buildPlayerPopup(
}



function getFieldProofImage(proof: FieldProof): string {
return proof.thumbnail_url || proof.image_url || ''
}

function createFieldProofIcon(proofs: FieldProof[]) {
const latest = proofs[0]
const image = escapeHtml(getFieldProofImage(latest))
const count = proofs.length

return L.divIcon({
className: 'saga-field-proof-photo-wrap',
html: `
<div class="saga-field-proof-photo-pin">
<img src="${image}" alt="" />
${count > 1 ? `<span>${count}</span>` : ''}
</div>
`,
iconSize: [52, 52],
iconAnchor: [26, 26],
})
}

function buildFieldProofPopup(proofs: FieldProof[]): string {
const items = proofs
.map((proof) => {
const image = escapeHtml(getFieldProofImage(proof))
const author = escapeHtml(proof.display_name || proof.user || 'Jugador')
const note = escapeHtml(proof.note || '')
const stage = escapeHtml(proof.stage_title || '')
return `
<article class="saga-field-proof-card">
<img src="${image}" alt="" />
<strong>${author}</strong>
${stage ? `<small>${stage}</small>` : ''}
${note ? `<p>${note}</p>` : ''}
</article>
`
})
.join('')

return `
<div class="saga-field-proof-popup">
<div class="saga-field-proof-popup-head">
<strong>Fotos de campo</strong>
<span>${proofs.length}</span>
</div>
<div class="saga-field-proof-carousel">${items}</div>
</div>
`
}

function createMissionNodeIcon(index: number, state: 'completed' | 'current' | 'locked', stage?: PlayerStage) {
const physicalVisual = getPhysicalNodeVisual(stage)
const label =
Expand Down Expand Up @@ -277,6 +330,7 @@ export function MapSurface({
focusRequest,
nodeState = 'locked',
otherPlayers = [],
fieldProofs = [],
selfLabel = 'YO',
selfProfile,
onDebugSetPosition,
Expand All @@ -291,6 +345,7 @@ export function MapSurface({
const playerAuraModeRef = useRef<'gps' | 'debug' | null>(null)
const otherPlayerMarkersRef = useRef<Map<string, L.Marker>>(new Map())
const otherPlayerMarkerStateRef = useRef<Map<string, string>>(new Map())
const fieldProofLayersRef = useRef<L.Layer[]>([])
const routeNodeLayersRef = useRef<L.Layer[]>([])
const onNodeTapRef = useRef(onNodeTap)
const lastNodeFrameRef = useRef<string | null>(null)
Expand Down Expand Up @@ -331,6 +386,8 @@ export function MapSurface({
routeNodeLayersRef.current = []
otherPlayerMarkersRef.current.forEach((marker) => marker.remove())
otherPlayerMarkersRef.current.clear()
fieldProofLayersRef.current.forEach((layer) => layer.remove())
fieldProofLayersRef.current = []
otherPlayerMarkerStateRef.current.clear()
map.remove()
mapRef.current = null
Expand Down Expand Up @@ -733,6 +790,55 @@ export function MapSurface({
}
}, [otherPlayers])

useEffect(() => {
const map = mapRef.current
if (!map) return

fieldProofLayersRef.current.forEach((layer) => layer.remove())
fieldProofLayersRef.current = []

const groups = new Map<string, FieldProof[]>()

for (const proof of fieldProofs) {
if (typeof proof.lat !== 'number' || typeof proof.lon !== 'number') continue

const key = proof.stage_id
? `stage:${proof.stage_id}`
: `${proof.lat.toFixed(4)}:${proof.lon.toFixed(4)}`

const group = groups.get(key) || []
group.push(proof)
groups.set(key, group)
}

for (const proofs of groups.values()) {
proofs.sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0))
const latest = proofs[0]
const marker = L.marker([latest.lat, latest.lon], {
icon: createFieldProofIcon(proofs),
keyboard: false,
riseOnHover: true,
bubblingMouseEvents: false,
zIndexOffset: 760,
}).addTo(map)

marker.bindPopup(buildFieldProofPopup(proofs), {
closeButton: true,
autoPan: true,
keepInView: true,
maxWidth: 280,
})

marker.bindTooltip(`📷 ${proofs.length > 1 ? `${proofs.length} fotos` : 'Foto de campo'}`, {
direction: 'top',
opacity: 0.92,
})

marker.on('click', () => marker.openPopup())
fieldProofLayersRef.current.push(marker)
}
}, [fieldProofs])

useEffect(() => {
const map = mapRef.current
if (!map || !focusRequest) return
Expand Down
Loading
Loading