1
0
Files
render-mathproblems/blocksystem/src/App.vue
Alonso Cárdenas 6b195e88c0 Add exercise type components
Add functioning exercise components for support. Includes test exercises to make sure they work.
ComposeDecompose: Takes a value and shows exercise with 2 inputs and hints automaticly. Can change the ammount of options it has.
Connectlines: Exercise  to connect between eachother. Has two modes, sum and picture, sum uses a 1+1 style of options, and picture uses the grid layout for more complexity. Picture in the middle is currently default blocks, this has to be conversed of how it should be displayed.
PictureChoose.vue: This one shows grid with the first and second value, the correct answer must have a boolean value stating this is the correct one.
Substractvisual.vue: This one is the most loose, as it only uses the grids as visuals and answer purposes. Not more than that.
Wheremore.vue: Has two modes, less or more (if non it defaults to more), Takes a left value and a right value to create the execise.

Examples of the structure are detailed in each component. And app.vue has demo exercises for testing and visual purposes.
2026-06-03 01:01:35 -04:00

1109 lines
32 KiB
Vue

<template>
<div class="app">
<!-- Start screen -->
<div v-if="screen === null" class="center">
<h1>🧱 Blocks</h1>
<button @click="screen = 'exercises'">Demo de Ejercicios</button>
<button @click="screen = 'creator'">Crear Ejercicio</button>
</div>
<!-- Demo Exercises screen -->
<div v-else-if="screen === 'exercises'" class="page">
<div class="bar">
<button @click="screen = null">Volver</button>
<button @click="resetAll">Reiniciar</button>
</div>
<div class="exercise-scroll">
<div class="exercise-list">
<Blocks v-for="(ex, idx) in demoExercises" :key="'demo-' + demoKeys[idx]" :exercise="ex"
@complete="r => onComplete('demo', idx, r)" />
</div>
</div>
</div>
<!-- User Exercises screen -->
<div v-else-if="screen === 'user-exercises'" class="page">
<div class="bar">
<button @click="screen = null">Volver</button>
<button @click="resetAllUser">Reiniciar</button>
<button @click="screen = 'creator'" style="margin-left:auto">+ Crear</button>
</div>
<div class="exercise-scroll">
<div class="exercise-list">
<div v-if="!userExercises.length" class="empty-state">No hay ejercicios creados todavía.</div>
<Blocks v-for="(ex, idx) in userExercises" :key="'user-' + userKeys[idx]" :exercise="ex"
@complete="r => onComplete('user', idx, r)" />
</div>
</div>
</div>
<!-- Creator screen -->
<div v-else-if="screen === 'creator'" class="page">
<div class="bar">
<button @click="screen = 'user-exercises'">Volver</button>
<span style="font-weight:600">Crear ejercicio</span>
<button v-if="draft.length" class="btn-load" @click="loadDraft" style="margin-left:auto">
Cargar {{ draft.length }} ejercicio{{ draft.length > 1 ? 's' : '' }}
</button>
</div>
<div class="creator-scroll">
<div class="creator-layout">
<!-- LEFT: form -->
<div class="creator-left">
<!-- Draft list -->
<div v-if="draft.length" class="draft-list">
<div v-for="(ex, i) in draft" :key="i" class="draft-item"
:class="{ 'draft-item--pinned': pinnedPreview === ex }"
@click="pinnedPreview = pinnedPreview === ex ? null : ex">
<span class="draft-nums">{{ex.grids.filter(g => g.mode !== 'target').map(g => g.label ||
g.value).join(' + ')}}</span>
<span class="draft-dropzones"> {{ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(',')
|| 'sin banca' }}</span>
<button class="btn-icon btn-delete"
@click.stop="draft.splice(i, 1); if (pinnedPreview === ex) pinnedPreview = null"></button>
</div>
</div>
<!-- Form -->
<div class="creator-form">
<!-- Exercise type -->
<div class="field">
<label>Tipo de ejercicio</label>
<div class="mode-toggle">
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'move-blocks' }"
@click="form.exerciseType = 'move-blocks'">Mover bloques</button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'input-check' }"
@click="form.exerciseType = 'input-check'">Escribir número</button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'sides' }"
@click="form.exerciseType = 'sides'">Lados </button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'mark-options' }"
@click="form.exerciseType = 'mark-options'">Marcar </button>
</div>
</div>
<!-- Layout -->
<div class="field" v-if="form.exerciseType !== 'sides' && form.exerciseType !== 'mark-options'">
<label>Disposición</label>
<div class="mode-toggle">
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
:class="{ 'mode-btn--active': form.layout === 'row' }" @click="form.layout = 'row'">Fila </button>
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
</button>
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
:class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
@click="form.layout = 'row-answer-right'">Resp. </button>
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
:class="{ 'mode-btn--active': form.layout === 'row-answer-bottom' }"
@click="form.layout = 'row-answer-bottom'">Resp. </button>
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
</button>
</div>
</div>
<!-- Answer position for input-check -->
<div class="field" v-if="form.exerciseType === 'input-check'">
<label>Posición de la casilla</label>
<div class="mode-toggle">
<button class="mode-btn" :class="{ 'mode-btn--active': form.answerPosition === 'right' }"
@click="form.answerPosition = 'right'">Derecha </button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.answerPosition === 'bottom' }"
@click="form.answerPosition = 'bottom'">Abajo </button>
</div>
</div>
<div class="field">
<label>Opciones</label>
<div class="mode-toggle">
<button class="mode-btn" :class="{ 'mode-btn--active': !form.hideBorders }"
@click="form.hideBorders = false">Bordes visibles</button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.hideBorders }"
@click="form.hideBorders = true">Sin bordes</button>
</div>
</div>
<div class="field">
<label>Enunciado <span class="hint">(opcional)</span></label>
<input v-model="form.text" type="text" placeholder="Ej: Pedro tiene 5 manzanas..." />
</div>
<div class="field">
<label>Grids <span class="hint"> bloques por grupo</span></label>
<div class="grids-col">
<div v-for="(g, i) in form.grids" :key="i" class="grid-entry">
<div class="grid-row">
<span class="grid-idx">{{ i + 1 }}</span>
<input class="grid-num-input" type="number" min="1" max="20" placeholder=""
:value="g.value > 0 ? g.value : ''" @input="onGridValueInput(i, $event)"
@blur="onGridBlur(i, $event)" />
<input v-if="g.value > 0" class="grid-label-input" type="text" :placeholder="'Nombre ' + (i + 1)"
v-model="g.label" />
</div>
<!-- Fruit picker dropdown select -->
<div v-if="g.value > 0" class="image-select-wrap">
<select class="image-select" v-model="g.image">
<option :value="null">🟥 Bloques de color</option>
<option v-for="f in FRUITS" :key="f.key" :value="f.key">
{{ f.emoji }} {{ f.label }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- mark-options: which grid is correct -->
<div class="field" v-if="form.exerciseType === 'mark-options' && validGrids.length >= 2">
<label>Respuesta correcta</label>
<select class="image-select" v-model="form.correctGridIdx" style="max-width:220px">
<option v-for="(g, i) in validGrids" :key="i" :value="i">
{{ g.label || ('Grid ' + (form.grids.indexOf(g) + 1)) }}
</option>
</select>
</div>
<!-- move-blocks / sides: banca config -->
<div class="field"
v-if="(form.exerciseType === 'move-blocks' || form.exerciseType === 'sides') && validGrids.length">
<label>Banca <span class="hint"> respuesta esperada por origen</span></label>
<div class="grid-row" style="margin-bottom:0.5rem">
<input class="grid-label-input" type="text" placeholder="Etiqueta respuesta" v-model="form.bancaLabel"
style="max-width:180px" />
</div>
<div class="needs-table">
<div v-for="(g, i) in validGrids" :key="i" class="needs-row">
<span class="needs-grid-idx">{{ form.grids.indexOf(g) + 1 }}</span>
<span class="needs-label">{{ g.label || ('Grid ' + (form.grids.indexOf(g) + 1)) }}</span>
<span class="needs-arrow"></span>
<input class="grid-num-input grid-num-input--sm" type="number" min="0" max="20" placeholder="0"
:value="g.needs > 0 ? g.needs : ''" @input="onGridNeedsInput(form.grids.indexOf(g), $event)" />
<span class="needs-hint">bloques</span>
</div>
</div>
</div>
<!-- input-check: expected answer -->
<div class="field" v-if="form.exerciseType === 'input-check' && validGrids.length">
<label>Respuesta correcta</label>
<div class="grid-row">
<input class="grid-num-input" type="number" min="0" max="99" placeholder="?"
v-model.number="form.expectedAnswer" />
<span class="needs-hint">valor esperado</span>
</div>
</div>
<button class="btn-add-draft" @click="addToDraft" :disabled="!canAdd">
+ Agregar a la lista
</button>
</div>
</div>
<!-- RIGHT: live preview -->
<div class="creator-right">
<div class="preview-label">Vista previa</div>
<div v-if="!previewExercise" class="preview-empty">
Ingresa al menos un número para ver el ejercicio.
</div>
<div v-else class="preview-card" :class="{ 'preview-card--last': !previewIsLive }">
<p v-if="!previewIsLive" class="preview-last-label">Último agregado</p>
<div class="preview-blocks-wrap">
<Blocks :key="previewKey" :exercise="previewExercise" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import Blocks from './components/Blocks.vue';
// ── SCREEN ──
const screen = ref(null);
// ── FRUIT CATALOGUE ──
// Keep in sync with IMAGE_DICT in Blocks.vue manually when adding/removing images.
const FRUITS = [
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
{ key: 'banana', emoji: '🍌', label: 'Banana' },
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
{ key: 'limon', emoji: '🍋', label: 'Limón' },
{ key: 'mango', emoji: '🥭', label: 'Mango' },
{ key: 'uva', emoji: '🍇', label: 'Uva' },
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
{ key: 'palta', emoji: '🥑', label: 'Palta' },
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
{ key: 'pina', emoji: '🍍', label: 'Piña' },
];
// ── DEMO EXERCISES ──
// New schema:
// exerciseType: 'move-blocks' | 'input-check' | 'mark-options'
// layout: 'row' | 'column' | 'row-answer-right' | 'row-answer-bottom'
// answerPosition (input-check only): 'right' | 'bottom'
// grids[].mode: 'active' | 'static' | 'target'
// grids[].id: string (used to match origins in answerSlot)
// answerSlot: { from: 'any' | string[], count: number } (for target grids)
// answerSlot: { expects: number } (for input-check at exercise level)
const demoExercises = ref([
// ════════════════════════════════════════════
// NEW EXERCISE COMPONENTS
// ════════════════════════════════════════════
// ── compose-decompose ──
{
exerciseType: 'compose-decompose',
text: '¿Qué números forman el 10?',
whole: 10, numberOfOptions: 4, image: 'manzana',
},
{
exerciseType: 'compose-decompose',
text: '¿Qué números forman el 7?',
whole: 7, numberOfOptions: 3, image: 'naranja',
},
// ── picture-choose ──
{
exerciseType: 'picture-choose',
text: 'Hay 5 manzanas y 4 naranjas. ¿Cuántas hay en total?',
firstImage: 'manzana', secondImage: 'naranja',
options: [
{ first: 3, second: 4, correct: false },
{ first: 5, second: 2, correct: false },
{ first: 5, second: 4, correct: true },
],
},
{
exerciseType: 'picture-choose',
text: 'Hay 6 fresas y 3 limones. ¿Cuántas hay en total?',
firstImage: 'frutilla', secondImage: 'limon',
options: [
{ first: 4, second: 3, correct: false },
{ first: 6, second: 3, correct: true },
{ first: 6, second: 5, correct: false },
{ first: 5, second: 4, correct: false },
],
},
// ── where-more ──
{
exerciseType: 'where-more',
text: '¿Dónde hay más? Marca con una X.',
mode: 'more',
comparisons: [
{ left: { value: 6, image: 'manzana' }, right: { value: 5, image: 'banana' } },
{ left: { value: 4, image: 'naranja' }, right: { value: 7, image: 'frutilla' } },
{ left: { value: 8, image: 'uva' }, right: { value: 3, image: 'limon' } },
],
},
{
exerciseType: 'where-more',
text: '¿Dónde hay menos? Marca con una X.',
mode: 'less',
comparisons: [
{ left: { value: 9, image: 'manzana' }, right: { value: 2, image: 'naranja' } },
{ left: { value: 3, image: 'banana' }, right: { value: 8, image: 'kiwi' } },
],
},
// ── connect: labels ──
{
exerciseType: 'connect',
text: 'Conecta las sumas que tienen el mismo resultado.',
columns: 2,
leftItems: [
{ id: 'l1', label: '3 + 5' },
{ id: 'l2', label: '2 + 4' },
{ id: 'l3', label: '4 + 5' },
],
rightItems: [
{ id: 'r1', label: '6 + 3' },
{ id: 'r2', label: '4 + 4' },
{ id: 'r3', label: '1 + 5' },
],
correctPairs: [
{ left: 'l1', right: 'r2' },
{ left: 'l2', right: 'r3' },
{ left: 'l3', right: 'r1' },
],
},
// ── connect: grids 2 columns ──
{
exerciseType: 'connect',
text: 'Conecta los que tienen la misma cantidad.',
columns: 2,
leftItems: [
{ id: 'l1', grids: [{ value: 3, image: 'manzana' }, { value: 5, image: 'manzana' }] },
{ id: 'l2', grids: [{ value: 2, image: 'naranja' }, { value: 4, image: 'naranja' }] },
{ id: 'l3', grids: [{ value: 4, image: 'banana' }, { value: 5, image: 'banana' }] },
],
rightItems: [
{ id: 'r1', grids: [{ value: 4, image: 'frutilla' }, { value: 4, image: 'frutilla' }] },
{ id: 'r2', grids: [{ value: 6, image: 'zanahoria' }, { value: 3, image: 'zanahoria' }] },
{ id: 'r3', grids: [{ value: 5, image: 'limon' }, { value: 1, image: 'limon' }] },
],
correctPairs: [
{ left: 'l1', right: 'r1' },
{ left: 'l2', right: 'r3' },
{ left: 'l3', right: 'r2' },
],
},
// ── connect: 3 columns ──
{
exerciseType: 'connect',
text: 'Conecta cada grupo con su número.',
columns: 3,
leftItems: [
{ id: 'l1', grids: [{ value: 4, image: 'naranja' }] },
{ id: 'l2', grids: [{ value: 2, image: 'manzana' }] },
{ id: 'l3', grids: [{ value: 3, image: 'banana' }] },
],
centerItems: [
{ id: 'c1', grids: [{ value: 2, color: '#6366f1' }] },
{ id: 'c2', grids: [{ value: 3, color: '#6366f1' }] },
{ id: 'c3', grids: [{ value: 4, color: '#6366f1' }] },
],
rightItems: [
{ id: 'r1', grids: [{ value: 3, image: 'limon' }] },
{ id: 'r2', grids: [{ value: 4, image: 'zanahoria' }] },
{ id: 'r3', grids: [{ value: 2, image: 'frutilla' }] },
],
correctPairs: [
{ left: 'l1', center: 'c3' },
{ left: 'l2', center: 'c1' },
{ left: 'l3', center: 'c2' },
{ right: 'r1', center: 'c2' },
{ right: 'r2', center: 'c3' },
{ right: 'r3', center: 'c1' },
],
},
// ── subtract-visual ──
{
exerciseType: 'subtract-visual',
text: 'Hay 5 manzanas. Se comieron 2. ¿Cuántas quedan?',
initialQuantity: 5, removedQuantity: 2, image: 'manzana',
options: [{ id: 'o1', value: 3 }, { id: 'o2', value: 4 }, { id: 'o3', value: 2 }, { id: 'o4', value: 1 }],
correctAnswer: 3,
},
{
exerciseType: 'subtract-visual',
text: 'Hay 9 naranjas. Se sacaron 4. ¿Cuántas quedan?',
initialQuantity: 9, removedQuantity: 4, image: 'naranja',
options: [{ id: 'o1', value: 5 }, { id: 'o2', value: 6 }, { id: 'o3', value: 3 }, { id: 'o4', value: 4 }],
correctAnswer: 5,
},
// ════════════════════════════════════════════
// LEGACY / BLOCKS-BASED EXERCISES
// ════════════════════════════════════════════
// ── move-blocks: answer right ──
{
exerciseType: 'move-blocks',
layout: 'row-answer-right',
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
grids: [
{ id: 'maria', mode: 'active', value: 2, label: 'María', image: 'manzana' },
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', image: 'zanahoria' },
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
],
},
// ── move-blocks: answer bottom, by origin ──
{
exerciseType: 'move-blocks',
layout: 'row-answer-bottom',
text: "Pedro tiene 3 manzanas y Alicia tiene 2. Pedro le da 2 a Alicia. ¿Cuántas tiene Alicia?",
grids: [
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', image: 'manzana' },
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', image: 'frutilla' },
{
id: 'resp', mode: 'target', label: 'Alicia al final',
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] }
},
],
},
// ── sides ──
{
exerciseType: 'sides',
text: "Junta los bloques de ambos lados en el centro.",
grids: [
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', image: 'manzana' },
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', image: 'banana' },
{ id: 'izq2', mode: 'active', value: 1, label: 'Grupo C', image: 'naranja' },
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 6 } },
],
},
// ── input-check ──
{
exerciseType: 'input-check',
layout: 'row', answerPosition: 'right',
text: "¿Cuántos hay en total?",
answerSlot: { expects: 5 },
grids: [
{ id: 'g1', mode: 'static', value: 2, label: 'Grupo 1', image: 'naranja' },
{ id: 'g2', mode: 'static', value: 3, label: 'Grupo 2' },
],
},
// ── mark-options ──
{
exerciseType: 'mark-options',
layout: 'row',
text: "¿Dónde hay menos? Marca con una X.",
answerSlot: { correctId: 'koalas' },
grids: [
{ id: 'pandas', mode: 'static', value: 6, label: 'Pandas' },
{ id: 'koalas', mode: 'static', value: 5, label: 'Koalas' },
],
},
]);
// ── USER EXERCISES ──
const userExercises = ref([]);
// ── KEYS (para forzar re-render al resetear) ──
const demoKeys = ref(demoExercises.value.map((_, i) => i * 100));
const userKeys = ref([]);
const growUserArrays = (exercises) => {
exercises.forEach(() => {
userKeys.value.push(userKeys.value.length * 100 + Date.now() % 1000);
});
};
// ── COMPLETE ──
const onComplete = (_group, _idx, _result) => { };
// ── RESET ──
const resetAll = () => { demoKeys.value = demoKeys.value.map(k => k + 1000); };
const resetAllUser = () => { userKeys.value = userKeys.value.map(k => k + 1000); };
// ── CREATOR ──
const defaultGrid = () => ({ value: '', label: '', needs: '', image: null });
const defaultForm = () => ({
text: '',
exerciseType: 'move-blocks',
layout: 'row-answer-right',
answerPosition: 'right',
grids: [defaultGrid()],
bancaLabel: '',
expectedAnswer: '',
});
const form = ref(defaultForm());
const draft = ref([]);
const lastAdded = ref(null);
const pinnedPreview = ref(null);
const validGrids = computed(() =>
form.value.grids.filter(g => parseInt(g.value) > 0)
);
const canAdd = computed(() => {
if (!validGrids.value.length) return false;
if (form.value.exerciseType === 'move-blocks')
return validGrids.value.some(g => parseInt(g.needs) > 0);
if (form.value.exerciseType === 'input-check')
return form.value.expectedAnswer !== '' && !isNaN(Number(form.value.expectedAnswer));
if (form.value.exerciseType === 'sides')
return validGrids.value.some(g => parseInt(g.needs) > 0);
if (form.value.exerciseType === 'mark-options') return validGrids.value.length >= 2;
return false;
});
// Grid inputs — cascade: fill → next slot appears
const onGridValueInput = (i, e) => {
const raw = parseInt(e.target.value);
const val = isNaN(raw) ? '' : Math.min(20, Math.max(1, raw));
e.target.value = val === '' ? '' : val;
form.value.grids[i].value = val;
if (i === form.value.grids.length - 1 && val > 0 && form.value.grids.length < 7)
form.value.grids.push(defaultGrid());
};
const onGridBlur = (i, e) => {
const parsed = parseInt(e.target.value);
if (!e.target.value || isNaN(parsed) || parsed <= 0) form.value.grids[i].value = '';
const gs = form.value.grids;
let last = gs.length - 1;
while (last > 0 && (!gs[last].value || parseInt(gs[last].value) <= 0)) last--;
form.value.grids = [...gs.slice(0, last + 1), defaultGrid()];
};
const onGridNeedsInput = (i, e) => {
const raw = parseInt(e.target.value);
const val = isNaN(raw) ? '' : Math.min(20, Math.max(0, raw));
e.target.value = val === '' ? '' : val;
form.value.grids[i].needs = val;
};
// ── PREVIEW ──
const previewKey = ref(0);
const previewIsLive = computed(() => validGrids.value.length > 0 && !pinnedPreview.value);
const previewExercise = computed(() => {
if (pinnedPreview.value) return pinnedPreview.value;
if (!validGrids.value.length) return lastAdded.value;
const srcGrids = validGrids.value.map((g, i) => ({
id: 'g' + i,
mode: form.value.exerciseType === 'move-blocks' ? 'active' : 'static',
value: parseInt(g.value),
label: g.label || String(g.value),
...(g.image ? { image: g.image } : {}),
}));
if (form.value.exerciseType === 'move-blocks') {
const answers = validGrids.value
.filter(g => parseInt(g.needs) > 0)
.map((g, _i, arr) => ({ id: 'g' + validGrids.value.indexOf(g), count: parseInt(g.needs) }));
return {
exerciseType: 'move-blocks',
layout: form.value.layout,
hideBorders: form.value.hideBorders,
text: form.value.text,
grids: [
...srcGrids,
{
id: 'target',
mode: 'target',
label: form.value.bancaLabel || 'Respuesta',
answerSlot: answers.length
? { from: answers }
: { from: 'any', count: srcGrids.reduce((s, g) => s + g.value, 0) },
},
],
};
}
if (form.value.exerciseType === 'input-check') {
return {
exerciseType: 'input-check',
layout: form.value.layout,
answerPosition: form.value.answerPosition,
hideBorders: form.value.hideBorders,
text: form.value.text,
answerSlot: { expects: Number(form.value.expectedAnswer) },
grids: srcGrids,
};
}
// sides: target in center, sources alternate left/right
if (form.value.exerciseType === 'sides') {
const answers = validGrids.value
.filter(g => parseInt(g.needs) > 0)
.map(g => ({ id: 'g' + validGrids.value.indexOf(g), count: parseInt(g.needs) }));
return {
exerciseType: 'sides',
hideBorders: form.value.hideBorders,
text: form.value.text,
grids: [
...srcGrids,
{
id: 'target',
mode: 'target',
label: form.value.bancaLabel || 'Respuesta',
answerSlot: answers.length
? { from: answers }
: { from: 'any', count: srcGrids.reduce((s, g) => s + g.value, 0) },
},
],
};
}
// mark-options
return {
exerciseType: 'mark-options',
hideBorders: form.value.hideBorders,
layout: 'row',
text: form.value.text,
answerSlot: { correctId: srcGrids[form.value.correctGridIdx]?.id ?? srcGrids[0]?.id },
grids: srcGrids,
};
});
watch(() => JSON.stringify(form.value), () => {
if (pinnedPreview.value && validGrids.value.length > 0) pinnedPreview.value = null;
}, { deep: true });
const addToDraft = () => {
if (!canAdd.value || !previewExercise.value) return;
const ex = JSON.parse(JSON.stringify(previewExercise.value));
draft.value.push(ex);
lastAdded.value = ex;
form.value = defaultForm();
};
const loadDraft = () => {
growUserArrays(draft.value);
userExercises.value.push(...draft.value);
draft.value = [];
lastAdded.value = null;
screen.value = 'user-exercises';
};
</script>
<style scoped>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.app {
width: 100%;
height: 100vh;
font-family: system-ui, sans-serif;
font-size: 14px;
background: #f0f4f8;
display: flex;
flex-direction: column;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 0.75rem;
}
.center h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.page {
height: 100vh;
display: flex;
flex-direction: column;
}
.bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: white;
border-bottom: 1px solid #ddd;
font-weight: 600;
color: #333;
flex-shrink: 0;
z-index: 500;
}
.exercise-scroll {
flex: 1;
overflow-y: auto;
padding: 1.5rem 1rem 3rem;
}
.exercise-list {
max-width: 860px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ── Creator ── */
.creator-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.creator-layout {
max-width: 900px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.creator-left {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.creator-form {
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field label {
font-size: 0.78rem;
font-weight: 700;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hint {
font-weight: 400;
color: #9ca3af;
text-transform: none;
letter-spacing: 0;
}
.field input[type="text"] {
width: 100%;
padding: 0.38rem 0.55rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
font-family: inherit;
background: white;
color: #333;
}
.field input[type="text"]:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
.grids-col {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.grid-entry {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.grid-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.grid-idx {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e0e7ff;
color: #6366f1;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.needs-table {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.needs-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
}
.needs-grid-idx {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e0e7ff;
color: #6366f1;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.needs-label {
color: #374151;
font-weight: 600;
min-width: 60px;
}
.needs-arrow {
color: #9ca3af;
}
.needs-hint {
color: #9ca3af;
font-size: 0.75rem;
}
.grid-num-input {
width: 58px;
padding: 0.35rem 0.4rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
font-family: inherit;
background: white;
color: #333;
text-align: center;
}
.grid-num-input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
.grid-num-input--sm {
width: 46px;
}
.grid-label-input {
flex: 1;
padding: 0.35rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8rem;
font-family: inherit;
background: white;
color: #555;
max-width: 130px;
}
.grid-label-input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
/* Preview */
.creator-right {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.preview-empty {
background: white;
border: 1px dashed #d1d5db;
border-radius: 8px;
padding: 2rem 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.85rem;
}
.preview-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: visible;
}
.preview-card--last {
border-color: #c7d2fe;
background: #fafbff;
}
.preview-last-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #818cf8;
padding: 0.5rem 0.75rem 0;
}
.preview-blocks-wrap {
pointer-events: none;
user-select: none;
}
/* Buttons */
button {
padding: 0.4rem 0.9rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
color: #333;
}
button:hover {
background: #f0f0f0;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.btn-icon {
padding: 0.2rem 0.45rem;
font-size: 0.78rem;
border-color: #e5e7eb;
color: #6b7280;
}
.btn-add-draft {
align-self: flex-start;
}
.btn-load {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.btn-load:hover {
background: #4f46e5;
}
.mode-toggle {
display: flex;
gap: 0;
border: 1px solid #d1d5db;
border-radius: 6px;
overflow: hidden;
width: fit-content;
}
.mode-btn {
padding: 0.3rem 0.75rem;
border: none;
border-radius: 0;
font-size: 0.8rem;
font-weight: 500;
background: white;
color: #6b7280;
cursor: pointer;
}
.mode-btn:hover {
background: #f0f0f0;
}
.mode-btn--active {
background: #6366f1;
color: white;
}
.mode-btn--active:hover {
background: #4f46e5;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 2rem 0;
font-size: 0.9rem;
}
.draft-list {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.draft-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
font-size: 0.82rem;
}
.draft-item:last-child {
border-bottom: none;
}
.draft-item:hover {
background: #fafbff;
}
.draft-item--pinned {
background: #eef2ff;
}
.draft-nums {
font-weight: 600;
color: #374151;
}
.draft-dropzones {
color: #9ca3af;
font-size: 0.78rem;
}
/* ── Fruit picker (select) ── */
.image-select-wrap {
padding: 2px 0 4px 26px;
}
.image-select {
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
background: white;
color: #374151;
cursor: pointer;
width: 100%;
max-width: 200px;
}
.image-select:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
</style>