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>
|
||||
Reference in New Issue
Block a user