Initial commit
add blocksystem files.
This commit is contained in:
896
blocksystem/src/App.vue
Normal file
896
blocksystem/src/App.vue
Normal file
@@ -0,0 +1,896 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="field" v-if="form.exerciseType !== 'sides'">
|
||||
<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="fruit-select-wrap">
|
||||
<select class="fruit-select" v-model="g.fruit">
|
||||
<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>
|
||||
|
||||
<!-- 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 ──
|
||||
// Keys must match what Blocks.vue / FRUIT_DICT accepts
|
||||
// Keys must match FRUIT_DICT in Blocks.vue exactly
|
||||
const FRUITS = [
|
||||
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
|
||||
{ key: 'banana', emoji: '🍌', label: 'Banana' },
|
||||
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
||||
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
|
||||
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
||||
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
||||
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
|
||||
{ key: 'mango', emoji: '🥭', label: 'Mango' },
|
||||
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
|
||||
{ key: 'palta', emoji: '🥑', label: 'Palta' },
|
||||
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
|
||||
{ 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([
|
||||
// ── move-blocks: operandos activos, banca target a la derecha ──
|
||||
{
|
||||
exerciseType: 'move-blocks',
|
||||
layout: 'row-answer-right',
|
||||
sidesAnswerLabel: '',
|
||||
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', fruit: 'manzana' },
|
||||
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', fruit: 'zanahoria' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── move-blocks: por origen específico, banca abajo ──
|
||||
{
|
||||
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', fruit: 'manzana' },
|
||||
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', fruit: 'frutilla' },
|
||||
{ id: 'resp', mode: 'target', label: 'Alicia al final',
|
||||
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check: grids estáticos, casilla de número a la derecha ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row',
|
||||
answerPosition: 'right',
|
||||
hideBorders: false,
|
||||
text: "¿Cuántos hay en total?",
|
||||
answerSlot: { expects: 5 },
|
||||
grids: [
|
||||
{ id: 'pollos', mode: 'static', value: 2, label: 'Pollos', fruit: 'naranja' },
|
||||
{ id: 'hamburguesas', mode: 'static', value: 3, label: 'Hamburguesas' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check: casilla de número abajo ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row',
|
||||
answerPosition: 'bottom',
|
||||
text: "¿Cuántos perros hay en el patio?",
|
||||
answerSlot: { expects: 6 },
|
||||
grids: [
|
||||
{ id: 'g1', mode: 'static', value: 4, label: 'Grupo 1', fruit: 'banana' },
|
||||
{ id: 'g2', mode: 'static', value: 2, label: 'Grupo 2', fruit: 'banana' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── sides: banca en el centro, fuentes alternando izquierda/derecha ──
|
||||
{
|
||||
exerciseType: 'sides',
|
||||
text: "Junta los bloques de ambos lados en el centro.",
|
||||
grids: [
|
||||
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', fruit: 'manzana' },
|
||||
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', fruit: 'banana' },
|
||||
{ id: 'izq2', mode: 'active', value: 2, label: 'Grupo C', fruit: 'naranja' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total',
|
||||
answerSlot: { from: 'any', count: 6 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── mark-options: dos grids estáticos, el alumno marca cuál tiene menos ── { 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: '', fruit: 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);
|
||||
return validGrids.value.length >= 2; // mark-options
|
||||
});
|
||||
|
||||
// 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.fruit ? { fruit: g.fruit } : {}),
|
||||
}));
|
||||
|
||||
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',
|
||||
layout: 'row',
|
||||
text: form.value.text,
|
||||
answerSlot: { correctId: 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) ── */
|
||||
.fruit-select-wrap {
|
||||
padding: 2px 0 4px 26px;
|
||||
}
|
||||
|
||||
.fruit-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;
|
||||
}
|
||||
|
||||
.fruit-select:focus {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
</style>
|
||||
141
blocksystem/src/README.md
Normal file
141
blocksystem/src/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# src
|
||||
|
||||
Esta carpeta contiene el código fuente completo de la aplicación. El núcleo del proyecto es `components/Blocks.vue`.
|
||||
|
||||
`App.vue` es un ejemplo funcional de cómo integrar y configurar `Blocks.vue` en una aplicación completa con modos interactivos.
|
||||
|
||||
---
|
||||
|
||||
## Blocks.vue
|
||||
|
||||
`Blocks.vue` es un componente autocontenido de Vue 3 que renderiza una cuadrícula interactiva de bloques unitarios arrastrables (1×1). Los usuarios pueden colocar bloques, arrastrarlos libremente, agruparlos y cortar conexiones entre bloques agrupados.
|
||||
|
||||
Está diseñado como herramienta educativa para explorar el agrupamiento de unidades y el valor posicional. Soporta configuraciones de múltiples grids, modos inline para integración en layouts complejos, y personalización avanzada a través de props.
|
||||
|
||||
### Características
|
||||
|
||||
- Arrastrar y soltar bloques en una cuadrícula configurable
|
||||
- Los bloques se agrupan automáticamente al colocarse adyacentes horizontalmente
|
||||
- Los grupos se colorean según su suma total
|
||||
- Cortar conexiones entre bloques usando el modo tijeras (✂️)
|
||||
- Barajar bloques en posiciones aleatorias (🔀)
|
||||
- Soporte completo de deshacer/rehacer (↶ ↷) y atajos de teclado (`Ctrl+Z`, `Ctrl+Y`)
|
||||
- El tamaño de las celdas de la cuadrícula se adapta automáticamente al viewport
|
||||
- La cuadrícula se puede arrastrar libremente dentro del overlay usando la barra de título
|
||||
- El estado se persiste en `localStorage` entre sesiones; se borra en recarga completa de página
|
||||
- La toolbar se puede ocultar vía prop para experiencias de solo lectura o restringidas
|
||||
- Soporte para múltiples grids con posiciones y configuraciones personalizadas
|
||||
- Modo inline para renderizado directo sin overlays y presentación compacta en la demo de ejercicios
|
||||
- Etiquetas y conteos configurables por grid
|
||||
|
||||
---
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `gridColumns` | `Number` | `8` | Número de columnas en la cuadrícula |
|
||||
| `gridRows` | `Number` | `7` | Número de filas en la cuadrícula |
|
||||
| `maxBloques` | `Number` | `Infinity` | Número máximo de bloques permitidos en la cuadrícula |
|
||||
| `maxPorGrupo` | `Number` | `Infinity` | Número máximo de unidades permitidas en un grupo conectado |
|
||||
| `cantidadBl` | `Number` | `10` | Número de bloques generados al barajar una cuadrícula vacía |
|
||||
| `initialBlocks` | `Array<Number>` | `[]` | Precarga bloques en el primer montaje. Cada número indica cuántos bloques agrupar en cada zona del grid. |
|
||||
| `showToolbar` | `Boolean` | `true` | Muestra u oculta la barra de herramientas (+1, 🔀, ✂️, ↶, ↷, 🗑️) |
|
||||
| `grids` | `Array<Object>` | `[]` | Array de configuraciones de grids múltiples. Cada objeto describe un grid independiente (sumando, resultado, etc.) usado especialmente en ejercicios. Puede incluir `label`, `cols`, `rows`, `position`, `initialBlocks`, `isAnswer`, `showLabel`, `showCount`. |
|
||||
| `inline` | `Boolean` | `false` | Renderiza los grids directamente sin overlay fullscreen ni FAB |
|
||||
| `inlineColumns` | `Number` | `0` | Número de columnas CSS para el contenedor inline (por defecto 'auto' = flex-row) |
|
||||
| `storageKey` | `String` | `'grid_blocks_data'` | Clave de localStorage para persistir el estado de los grids |
|
||||
| `noSnap` | `Boolean` | `false` | Desactiva el acoplamiento entre bloques (y el modo corte) |
|
||||
| `showGridLabels` | `Boolean` | `true` | Muestra los números debajo de cada grid (sumandos y respuesta) |
|
||||
|
||||
### Configuración de Grids Múltiples
|
||||
|
||||
Cuando se proporciona el prop `grids`, el componente renderiza múltiples grids en lugar de uno solo. Cada objeto dentro del array describe un grid independiente y permite armar por separado los elementos de cada ejercicio: por ejemplo, un grid para cada sumando y otro grid para la respuesta.
|
||||
|
||||
- `label`: Etiqueta personalizada para el grid (por defecto "Grid N")
|
||||
- `cols` / `rows`: Dimensiones específicas
|
||||
- `position`: Posición en el overlay ('top-left', 'center', etc.)
|
||||
- `initialBlocks`: Bloques iniciales para ese grid
|
||||
- `isAnswer`: Marca el grid como área de respuesta
|
||||
- `showLabel` / `showCount`: Controla la visibilidad de etiquetas y conteos
|
||||
|
||||
En ejercicios de suma, `grids` se usa para generar los grids de cada sumando y un grid final de respuesta. Cada objeto puede representar un sumando distinto o el espacio donde el alumno debe colocar el resultado. El modo `inline` se utiliza en la demo para mostrar los grids de forma compacta en la misma interfaz, con `inlineColumns` controlando la disposición horizontal/vertical y `showGridLabels` para ajustar la presentación del ejercicio.
|
||||
|
||||
```vue
|
||||
<Blocks :grids="[
|
||||
{ label: 'Sumando 1', cols: 5, rows: 1, initialBlocks: [3], showLabel: false },
|
||||
{ label: 'Sumando 2', cols: 5, rows: 1, initialBlocks: [4], showLabel: false },
|
||||
{ label: 'Respuesta', cols: 10, rows: 2, initialBlocks: [], isAnswer: true, showLabel: true }
|
||||
]" :inline="true" :inlineColumns="1" />
|
||||
```
|
||||
|
||||
> Si `showToolbar` es `false`, `initialBlocks` debe proporcionarse — de lo contrario no hay forma de agregar bloques a la cuadrícula.
|
||||
|
||||
---
|
||||
|
||||
### Color System
|
||||
|
||||
Block color reflects the sum of the connected group:
|
||||
|
||||
| Sum | Color |
|
||||
|---|---|
|
||||
| 1 | Red |
|
||||
| 2 | Orange |
|
||||
| 3 | Green |
|
||||
| 4 | Blue |
|
||||
| 5 | Purple |
|
||||
| 6 | Pink |
|
||||
| 7 | Cyan |
|
||||
| 8 | Dark orange |
|
||||
| 9 | Teal |
|
||||
| 10–19 | Dark blue (1st ten) |
|
||||
| 20–29 | Medium blue (2nd ten) |
|
||||
| 30–39 | Light blue (3rd ten) |
|
||||
| 40+ | Lighter blue (4th ten) |
|
||||
|
||||
When a group has more than 10 units, the first 10 cubes take the color of their decade and the remaining cubes take the color of that remainder value.
|
||||
|
||||
---
|
||||
|
||||
### Scissors Mode (✂️)
|
||||
|
||||
Activating scissors mode shows dashed pink lines between all connected block pairs. To cut a connection, click and drag from one end of a connection line to the opposite end. When the cursor reaches the other vertex, the connection is severed and the groups split.
|
||||
|
||||
---
|
||||
|
||||
## App.vue
|
||||
|
||||
`App.vue` es una implementación de referencia completa que envuelve `Blocks.vue` con una interfaz de usuario avanzada. Incluye modos interactivos para demostración y aprendizaje, sirviendo tanto para integrar el componente como para fines educativos.
|
||||
|
||||
### Características Principales
|
||||
|
||||
- **Modo Inicio**: Pantalla de bienvenida con opciones para acceder a la demo de configuración o a los ejercicios de suma.
|
||||
- **Demo de Configuración**: Permite configurar grids personalizados mediante un modal interactivo, ajustando etiquetas, dimensiones, posiciones y bloques iniciales. Incluye una vista previa en tiempo real.
|
||||
- **Ejercicios de Suma**: Serie de ejercicios matemáticos donde los usuarios resuelven problemas de suma utilizando los grids. Cada ejercicio incluye:
|
||||
- Enunciados en lenguaje natural.
|
||||
- Grids inline para sumandos y respuesta.
|
||||
- Verificación automática de respuestas.
|
||||
- Indicadores visuales de corrección o error.
|
||||
- Opción para mostrar/ocultar números en los grids.
|
||||
- Reinicio individual o global de ejercicios.
|
||||
|
||||
La aplicación valida configuraciones (por ejemplo, requiere bloques iniciales si la toolbar está oculta) y persiste el estado de los ejercicios en `localStorage`.
|
||||
|
||||
### Ejemplo de Integración Mínima
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import Blocks from './components/Blocks.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Blocks
|
||||
:gridColumns="8"
|
||||
:gridRows="7"
|
||||
:maxBloques="20"
|
||||
:maxPorGrupo="5"
|
||||
:initialBlocks="[3, 5, 2]"
|
||||
:showToolbar="true"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
2776
blocksystem/src/components/Blocks.vue
Normal file
2776
blocksystem/src/components/Blocks.vue
Normal file
File diff suppressed because it is too large
Load Diff
4
blocksystem/src/main.ts
Normal file
4
blocksystem/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
Reference in New Issue
Block a user