Skip to content
Merged
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
137 changes: 135 additions & 2 deletions frontend/src/admin/AdminApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
type EditableAdminStage,
type FamilyId,
} from './lib/familyConfigs'
import {
getDefaultAdminStagePatchForGame,
getMissionTemplateById,
type MissionTemplateId,
} from './lib/gameCatalog'
import {
buildPlayerDrafts,
normalizePlayerId,
Expand All @@ -40,8 +45,37 @@ import { getStablePlayerColor, getPlayerInitials } from '../shared/playerIdentit

type LoadState = 'loading' | 'ready' | 'error'
type OverviewState = 'locked' | 'loading' | 'ready' | 'error'
type CmsPanel = 'none' | 'players' | 'mission' | 'labels'

type CmsPanel = 'none' | 'players' | 'mission' | 'labels' | 'builder' | 'builder'


function slugifyMissionItemId(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 80)
}

function buildTemplatePhysicalFields(kind: 'collectible' | 'requirement' | 'clue' | 'bonus', label: string) {
const itemId = slugifyMissionItemId(label) || 'objeto_qr'
const payload = `saga:item:${itemId}`

return {
physical_node_kind: kind,
physical_item_kind: kind,
physical_item_id: itemId,
physical_item_label: label,
physical_qr: {
kind,
item_id: itemId,
label,
payload,
},
qr_payload: payload,
}
}

function preservePhysicalStageFields<T extends Record<string, unknown>>(previous: T, next: T): T {
const keys = [
Expand Down Expand Up @@ -614,6 +648,104 @@ export default function AdminApp() {
setLocalNotice('Local preview updated. Save changes to persist.')
}

function applyMissionTemplate(templateId: MissionTemplateId) {
if (!overview) return

const template = getMissionTemplateById(templateId)
const shouldReplace =
stages.length === 0 ||
window.confirm(`Reemplazar la ruta local actual por la plantilla "${template.title}"? Guarda después para persistir.`)

if (!shouldReplace) return

const mapCenter =
overview?.config?.map_center ||
config?.map_center ||
([40.4168, -3.7038] as [number, number])

const centerLat = Number(mapCenter[0] || 40.4168)
const centerLon = Number(mapCenter[1] || -3.7038)
let lastPhysicalItem: { id: string; label: string } | null = null

const nextStages = template.stages.map((item, index) => {
const patch = getDefaultAdminStagePatchForGame(item.gameId)
const lat = centerLat + item.offsetLat
const lon = centerLon + item.offsetLon
const physicalFields = item.physicalKind
? buildTemplatePhysicalFields(item.physicalKind, item.itemLabel || item.title)
: {}

if (item.physicalKind) {
const record = physicalFields as { physical_item_id?: string; physical_item_label?: string }
lastPhysicalItem = {
id: record.physical_item_id || slugifyMissionItemId(item.itemLabel || item.title),
label: record.physical_item_label || item.itemLabel || item.title,
}
}

const requirementConfig =
item.requiresPreviousItem && lastPhysicalItem
? {
required_item_id: lastPhysicalItem.id,
required_item_label: lastPhysicalItem.label,
required_item_quantity: 1,
required_item_consume: false,
}
: {}

return {
id: `local-template-${Date.now()}-${index}`,
index,
title: item.title,
type: patch.type,
label: patch.label,
icon: patch.icon,
lat,
lon,
radius: item.radius || 50,
entry_mode: 'gps',
require_proximity: true,
has_hint: false,
has_manual_fallback: false,
content: item.content || patch.content,
objective: patch.objective,
config: {
...patch.config,
...requirementConfig,
},
config_summary: Array.from(new Set([...patch.config_summary, ...Object.keys(requirementConfig)])),
messages: patch.messages,
...physicalFields,
} as EditableAdminStage
})

const familyCounts = nextStages.reduce<Record<string, number>>((acc, stage) => {
const family = stage.type || 'signal_hunt'
acc[family] = (acc[family] || 0) + 1
return acc
}, {})

setOverview((current) => current
? {
...current,
stages: nextStages,
counts: current.counts
? {
...current.counts,
stages: nextStages.length,
family_counts: familyCounts,
}
: current.counts,
}
: current
)

setSelectedStage(nextStages[0] || null)
setCmsPanel('none')
setSaveState('idle')
setLocalNotice(`Plantilla "${template.title}" creada en local. Revisa los nodos y pulsa Guardar.`)
}

function createLocalNodeAt(lat?: number, lon?: number) {
const mapCenter =
overview?.config?.map_center ||
Expand Down Expand Up @@ -774,6 +906,7 @@ export default function AdminApp() {
onSavePlayers={savePlayerProfiles}
onUpdateMissionDraft={updateMissionDraft}
onSaveSettings={saveMissionSettings}
onApplyMissionTemplate={applyMissionTemplate}
/>
</>
)
Expand Down
31 changes: 24 additions & 7 deletions frontend/src/admin/components/AdminMissionControlShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import NodeDetailDrawer from './NodeDetailDrawer'
import NodePhysicalTypePanel from './NodePhysicalTypePanel'
import PlayersPanel from './PlayersPanel'
import SettingsPanel from './SettingsPanel'
import MissionBuilderPanel from './MissionBuilderPanel'
import type { AdminReactOverviewProfile, AdminReactOverviewStage } from '../lib/adminApi'
import { familyCards } from '../lib/familyConfigs'
import type { MissionTemplateId } from '../lib/gameCatalog'
import type { PlayerDraft } from '../lib/playerDrafts'
import { getPhysicalNodeVisual } from '../lib/physicalNodeVisuals'
import '../styles/admin-modern-shell.css'

type CmsPanel = 'none' | 'players' | 'mission' | 'labels'
type CmsPanel = 'none' | 'players' | 'mission' | 'labels' | 'builder' | 'builder'
type SaveState = 'idle' | 'saving' | 'saved' | 'error'

type AdminMissionControlShellProps = {
Expand Down Expand Up @@ -47,6 +49,7 @@ type AdminMissionControlShellProps = {
onSavePlayers: () => void
onUpdateMissionDraft: (key: string, value: string) => void
onSaveSettings: () => void
onApplyMissionTemplate: (templateId: MissionTemplateId) => void
}

function selectedStageKey(stage: AdminReactOverviewStage | null) {
Expand Down Expand Up @@ -92,6 +95,7 @@ export default function AdminMissionControlShell({
onSavePlayers,
onUpdateMissionDraft,
onSaveSettings,
onApplyMissionTemplate,
}: AdminMissionControlShellProps) {
const [typeChooserStageKey, setTypeChooserStageKey] = useState<string | null>(null)

Expand Down Expand Up @@ -171,7 +175,7 @@ export default function AdminMissionControlShell({
</section>

<nav className="saga-rail-actions" aria-label="Primary admin actions">
<button type="button" className="saga-primary-action" onClick={onCreateNode}>
<button type="button" className="saga-primary-action" onClick={() => togglePanel('builder')}>
+ Add node
</button>

Expand All @@ -189,7 +193,7 @@ export default function AdminMissionControlShell({
</nav>

<div className="saga-panel-switcher">
<button
<button
type="button"
className={cmsPanel === 'players' ? 'active' : ''}
onClick={() => togglePanel('players')}
Expand Down Expand Up @@ -278,7 +282,7 @@ export default function AdminMissionControlShell({
})}

{stages.length === 0 ? (
<div className="saga-empty-mini">Click the map to create the first node.</div>
<div className="saga-empty-mini">Pulsa Añadir nodo para crear un nodo suelto o arrancar una plantilla.</div>
) : null}
</div>
</section>
Expand All @@ -287,7 +291,7 @@ export default function AdminMissionControlShell({
<section className="saga-map-workspace" aria-label="Map workspace">
<div className="saga-command-bar">
<div className="saga-command-main">
<button type="button" className="saga-command-primary" onClick={onCreateNode}>
<button type="button" className="saga-command-primary" onClick={() => togglePanel('builder')}>
Add node
</button>
<button type="button" onClick={onSaveStages} disabled={saveState === 'saving'}>
Expand Down Expand Up @@ -365,6 +369,7 @@ export default function AdminMissionControlShell({
) : (
<NodeDetailDrawer
stage={liveSelectedStage}
stages={stages}
onClose={() => onSelectStage(null)}
onApplyLocal={onApplyStage}
onDeleteLocal={onDeleteStage}
Expand All @@ -380,11 +385,22 @@ export default function AdminMissionControlShell({
{cmsPanel !== 'none' ? (
<aside className="saga-floating-panel" aria-label="CMS panel">
<div className="saga-floating-head">
<strong>{cmsPanel === 'players' ? 'Players' : cmsPanel === 'labels' ? 'Families' : 'Mission settings'}</strong>
<strong>{cmsPanel === 'players' ? 'Players' : cmsPanel === 'labels' ? 'Families' : cmsPanel === 'builder' ? 'Crear' : 'Mission settings'}</strong>
<button type="button" onClick={() => onSetCmsPanel('none')}>Close</button>
</div>

<div className="saga-floating-body">
{cmsPanel === 'builder' ? (
<MissionBuilderPanel
stages={stages}
onCreateNode={() => {
onSetCmsPanel('none')
onCreateNode()
}}
onApplyTemplate={onApplyMissionTemplate}
/>
) : null}

{cmsPanel === 'players' ? (
<PlayersPanel
playerDrafts={playerDrafts}
Expand Down Expand Up @@ -413,8 +429,9 @@ export default function AdminMissionControlShell({
) : null}

<nav className="saga-mobile-actions" aria-label="Mobile actions">
<button type="button" onClick={onCreateNode}>+ Node</button>
<button type="button" onClick={() => togglePanel('builder')}>+ Node</button>
<button type="button" onClick={onSaveStages}>Save</button>
<button type="button" onClick={() => togglePanel('builder')}>Builder</button>
<button type="button" onClick={() => togglePanel('players')}>Players</button>
<button type="button" onClick={() => togglePanel('mission')}>Settings</button>
</nav>
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/admin/components/FamiliesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { familyCards } from '../lib/familyConfigs'
import { adminGameCatalog } from '../lib/gameCatalog'

export default function FamiliesPanel() {
return (
<div className="admin-cms-local-panel">
<strong>Families / labels</strong>
<span>Available family-native runtime labels.</span>
<strong>Juegos disponibles</strong>
<span>13 plantillas editables. Internamente usan 3 motores estables: GPS, brújula y lógica.</span>

<div className="admin-local-list">
{adminGameCatalog.map((game) => (
<div key={game.id} className="admin-local-row static admin-game-list-row">
<span>{game.icon} {game.title}</span>
<small>{game.difficulty} · {game.duration} · {game.summary}</small>
</div>
))}
</div>

<strong>Motores internos</strong>
<span>Estos son los runtimes que ejecuta el player actualmente.</span>

<div className="admin-local-list">
{familyCards.map((family) => (
<div key={family.id} className="admin-local-row static">
<span>{family.icon} {family.title}</span>
<small>{family.id}</small>
<small>{family.id} · {family.detail}</small>
</div>
))}
</div>
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/admin/components/MissionBuilderPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { missionTemplates, type MissionTemplateId } from '../lib/gameCatalog'
import type { AdminReactOverviewStage } from '../lib/adminApi'

type MissionBuilderPanelProps = {
stages: AdminReactOverviewStage[]
onCreateNode: () => void
onApplyTemplate: (templateId: MissionTemplateId) => void
}

export default function MissionBuilderPanel({
stages,
onCreateNode,
onApplyTemplate,
}: MissionBuilderPanelProps) {
return (
<div className="admin-cms-local-panel saga-mission-builder-panel">
<strong>Crear contenido</strong>
<span>
Crea un nodo suelto para editarlo a mano, o arranca una plantilla completa de misión.
Nada se guarda hasta pulsar Guardar.
</span>

<button type="button" className="saga-builder-single-node" onClick={onCreateNode}>
<span>+</span>
<div>
<strong>Crear nodo suelto</strong>
<small>Empieza con un nodo normal y elige después si será QR, pista, bonus o minijuego.</small>
</div>
</button>

{stages.length > 0 ? (
<div className="saga-builder-warning">
Las plantillas reemplazan la ruta local visible. No se persiste nada hasta pulsar Guardar.
</div>
) : null}

<div className="saga-template-grid">
{missionTemplates.map((template) => (
<article key={template.id} className="saga-template-card">
<div className="saga-template-card-head">
<span>{template.icon}</span>
<div>
<strong>{template.title}</strong>
<small>{template.goodFor}</small>
</div>
</div>

<p>{template.summary}</p>

<ol>
{template.stages.map((stage) => (
<li key={`${template.id}-${stage.title}`}>{stage.title}</li>
))}
</ol>

<button type="button" onClick={() => onApplyTemplate(template.id)}>
Usar plantilla
</button>
</article>
))}
</div>
</div>
)
}
Loading
Loading