Initial commit
add blocksystem files.
This commit is contained in:
335
blocksystem/.gitignore
vendored
Normal file
335
blocksystem/.gitignore
vendored
Normal file
@@ -0,0 +1,335 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python,node,visualstudiocode
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python,node,visualstudiocode
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
#Other
|
||||
.vscode/
|
||||
public/
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python,node,visualstudiocode
|
||||
42
blocksystem/README.md
Normal file
42
blocksystem/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# BlockSystem
|
||||
|
||||
BlockSystem es una aplicación educativa interactiva construida con Vue 3, centrada en el componente **`Blocks.vue`**. Este componente permite a los usuarios colocar, arrastrar, agrupar y dividir bloques unitarios en una cuadrícula, fomentando la intuición matemática sobre agrupamiento y valor posicional (unidades y decenas).
|
||||
|
||||
La aplicación incluye una interfaz completa con modos de demostración y ejercicios prácticos de suma, diseñada para apoyar el aprendizaje de conceptos matemáticos básicos de manera visual e interactiva.
|
||||
|
||||
`App.vue` sirve como implementación de referencia, mostrando cómo integrar y configurar `Blocks.vue` en una aplicación real, incluyendo configuraciones avanzadas y ejercicios educativos. El proyecto ahora documenta con más precisión el uso del modo `inline` para mostrar grids compactos en la demo de ejercicios, y mantiene `initialBlocks` como una forma general de precargar grupos de bloques sin depender de un layout fijo. La demo de ejercicios también incluye una zona de dropzone visual junto al grid para validar la respuesta al soltar la solución final.
|
||||
|
||||
Para detalles de la API y configuración de `Blocks.vue`, consulta `src/README.md`.
|
||||
|
||||
## Requisitos
|
||||
|
||||
Antes de ejecutar el proyecto, asegúrate de tener instalados:
|
||||
- Node.js (versión recomendada: 18+)
|
||||
- npm o yarn
|
||||
|
||||
Puedes descargar Node.js desde: [https://nodejs.org/](https://nodejs.org/)
|
||||
|
||||
## Configuración
|
||||
|
||||
Navega al directorio raíz del proyecto y ejecuta el siguiente comando en la terminal:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
Este comando instalará automáticamente Vue, Vite, TypeScript y las configuraciones necesarias de @vue/tsconfig para el proyecto.
|
||||
|
||||
### Ejecutar la Aplicación
|
||||
|
||||
Para iniciar el servidor de desarrollo, ejecuta:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Esto lanzará la aplicación en modo desarrollo utilizando Vite.
|
||||
|
||||
## Configuración Recomendada del IDE
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (y desactiva Vetur).
|
||||
|
||||
8
blocksystem/env.d.ts
vendored
Normal file
8
blocksystem/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
|
||||
13
blocksystem/index.html
Normal file
13
blocksystem/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2853
blocksystem/package-lock.json
generated
Normal file
2853
blocksystem/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
blocksystem/package.json
Normal file
30
blocksystem/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "blocksystem",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.11.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
1849
blocksystem/pnpm-lock.yaml
generated
Normal file
1849
blocksystem/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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')
|
||||
18
blocksystem/tsconfig.app.json
Normal file
18
blocksystem/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
11
blocksystem/tsconfig.json
Normal file
11
blocksystem/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
blocksystem/tsconfig.node.json
Normal file
28
blocksystem/tsconfig.node.json
Normal file
@@ -0,0 +1,28 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
18
blocksystem/vite.config.ts
Normal file
18
blocksystem/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user