Italiano Vivo MVP: spec, monorepo app+server, 18 missoes (bambino+viaggio), audio TTS edge-tts, judge 3-eixos CEFR, SRS, PWA offline
parent
aa4fe5865f
commit
7a5f5be5dc
@ -0,0 +1,12 @@
|
|||||||
|
# Copie para .env (server/.env). NUNCA commite o .env real.
|
||||||
|
# LiteLLM proxy local (gratuito, dentro da assinatura Token Plan)
|
||||||
|
LITELLM_BASE_URL=http://10.8.0.1:4000/v1
|
||||||
|
LITELLM_API_KEY=coloqu…aqui
|
||||||
|
|
||||||
|
# Modelos
|
||||||
|
CHAT_MODEL=qwen3.8-max # juiz de feedback / geracao de missões
|
||||||
|
FAST_MODEL=qwen3.8-flash # drills e tarefas leves
|
||||||
|
OMNI_MODEL=qwen3-omni-flash # transcricao de audio do aluno (ASR tolerante a sotaque)
|
||||||
|
|
||||||
|
# Porta do server
|
||||||
|
PORT=8787
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
server/cache/
|
||||||
|
app/dist-ssr/
|
||||||
|
coverage/
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#009246" />
|
||||||
|
<meta name="description" content="Aprenda italiano com voz livre — situacional-first, tolerante a erros" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>Italiano Vivo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "italiano-vivo-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dexie": "^4.0.11",
|
||||||
|
"dexie-react-hooks": "^1.1.7",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Italiano Vivo",
|
||||||
|
"short_name": "ItalianoVivo",
|
||||||
|
"description": "Aprenda italiano com voz livre — situacional-first, tolerante a erros",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#009246",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Service Worker — Italiano Vivo
|
||||||
|
* Estratégia:
|
||||||
|
* - Shell (HTML/CSS/JS do app): cache-first
|
||||||
|
* - /content/* e /audio/*: cache-first com network fallback + runtime caching
|
||||||
|
* - /api/*: network-only (nunca cachear)
|
||||||
|
*
|
||||||
|
* Objetivo: modo resgate offline — frases + áudio já baixados funcionam no avião.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = "italiano-vivo-v1";
|
||||||
|
const SHELL_ASSETS = ["/", "/index.html"];
|
||||||
|
|
||||||
|
// ─── Install: pré-cache do shell ───
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)),
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Activate: limpar caches antigos ───
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((k) => k !== CACHE_NAME)
|
||||||
|
.map((k) => caches.delete(k)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Fetch: roteamento por padrão de URL ───
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// API: sempre network-only
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return; // deixa o browser fazer o fetch normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content e Audio: cache-first com network fallback + runtime caching
|
||||||
|
if (pathname.startsWith("/content/") || pathname.startsWith("/audio/")) {
|
||||||
|
event.respondWith(cacheFirstWithRuntimeCache(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell e demais assets: cache-first
|
||||||
|
event.respondWith(cacheFirst(event.request));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-first: tenta cache, se não achar vai na rede (sem salvar no cache).
|
||||||
|
* Usado para shell/assets que são pré-cachados no install.
|
||||||
|
*/
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-first com runtime caching: tenta cache, se não achar vai na rede
|
||||||
|
* e salva a resposta no cache para uso offline futuro.
|
||||||
|
* Usado para /content/* e /audio/* que são baixados sob demanda.
|
||||||
|
*/
|
||||||
|
async function cacheFirstWithRuntimeCache(request) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
// Só cachear respostas OK (não cachear 404/500)
|
||||||
|
if (response.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (_err) {
|
||||||
|
// Offline e sem cache: retorna erro genérico
|
||||||
|
return new Response("Offline — Italiano Vivo", {
|
||||||
|
status: 503,
|
||||||
|
statusText: "Service Unavailable",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Banco de dados IndexedDB via Dexie.
|
||||||
|
* Armazena progresso, tentativas, cartões SRS, log de resgate e configurações.
|
||||||
|
*/
|
||||||
|
import Dexie, { type EntityTable } from "dexie";
|
||||||
|
|
||||||
|
export interface ProgressRecord {
|
||||||
|
missionId: string;
|
||||||
|
completedAt?: number;
|
||||||
|
bestMeaningScore?: "full" | "partial" | "failed";
|
||||||
|
attemptsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttemptRecord {
|
||||||
|
id?: number;
|
||||||
|
missionId: string;
|
||||||
|
ts: number;
|
||||||
|
text: string;
|
||||||
|
verdict: {
|
||||||
|
meaning_conveyed: "full" | "partial" | "failed";
|
||||||
|
naturalness: number;
|
||||||
|
natural_version: string;
|
||||||
|
grammar: Array<{
|
||||||
|
it_said: string;
|
||||||
|
problem_pt: string;
|
||||||
|
fix_it: string;
|
||||||
|
severity: "blocks_understanding" | "minor" | "cosmetic";
|
||||||
|
}>;
|
||||||
|
praise_or_push: string;
|
||||||
|
};
|
||||||
|
/** Se o aluno marcou "isso estava certo" (dissidência) */
|
||||||
|
dissent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SRSCard {
|
||||||
|
id: string;
|
||||||
|
chunkIt: string;
|
||||||
|
chunkPt: string;
|
||||||
|
audioKey?: string;
|
||||||
|
box: number;
|
||||||
|
interval: number;
|
||||||
|
due: number;
|
||||||
|
lastSeen: number;
|
||||||
|
easeFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RescueLogEntry {
|
||||||
|
id?: number;
|
||||||
|
missionId: string;
|
||||||
|
ts: number;
|
||||||
|
rescueCard: {
|
||||||
|
come_si_dice: string;
|
||||||
|
frase_it: string;
|
||||||
|
explicacao_pt: string;
|
||||||
|
};
|
||||||
|
used: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ItalianoVivoDB extends Dexie {
|
||||||
|
progress!: EntityTable<ProgressRecord, "missionId">;
|
||||||
|
attempts!: EntityTable<AttemptRecord, "id">;
|
||||||
|
srsCards!: EntityTable<SRSCard, "id">;
|
||||||
|
rescueLog!: EntityTable<RescueLogEntry, "id">;
|
||||||
|
settings!: EntityTable<AppSettings, "key">;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("ItalianoVivo");
|
||||||
|
this.version(1).stores({
|
||||||
|
progress: "missionId",
|
||||||
|
attempts: "++id, missionId, ts",
|
||||||
|
srsCards: "id, due, box",
|
||||||
|
rescueLog: "++id, missionId, ts",
|
||||||
|
settings: "key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new ItalianoVivoDB();
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* SM-2 simplificado para revisão espaçada de chunks.
|
||||||
|
* Qualidade 0-5 derivada do veredito:
|
||||||
|
* full = 4, partial = 2, failed = 1
|
||||||
|
* + bônus se o aluno regravou (tentou de novo)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SRSCard } from "../db/db.js";
|
||||||
|
|
||||||
|
/** Intervalos iniciais em dias por box (SM-2 simplificado) */
|
||||||
|
const BASE_INTERVALS = [0, 1, 3, 7, 14, 30, 60];
|
||||||
|
|
||||||
|
export interface ReviewInput {
|
||||||
|
meaningConveyed: "full" | "partial" | "failed";
|
||||||
|
reRecorded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converte veredito do judge em qualidade SM-2 (0-5) */
|
||||||
|
export function verdictToQuality(input: ReviewInput): number {
|
||||||
|
let quality: number;
|
||||||
|
|
||||||
|
switch (input.meaningConveyed) {
|
||||||
|
case "full":
|
||||||
|
quality = 4;
|
||||||
|
break;
|
||||||
|
case "partial":
|
||||||
|
quality = 2;
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
quality = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bônus por re-gravação: mostra esforço e engajamento
|
||||||
|
if (input.reRecorded && quality < 5) {
|
||||||
|
quality = Math.min(quality + 1, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula o próximo estado do cartão SRS após uma revisão.
|
||||||
|
* Função pura — sem efeitos colaterais.
|
||||||
|
*/
|
||||||
|
export function review(card: SRSCard, quality: number): SRSCard {
|
||||||
|
const now = Date.now();
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
let { box, interval, easeFactor } = card;
|
||||||
|
|
||||||
|
if (quality < 3) {
|
||||||
|
// Falhou: resetar para box 0
|
||||||
|
box = 0;
|
||||||
|
interval = 0;
|
||||||
|
} else {
|
||||||
|
// Sucesso: avançar box
|
||||||
|
box = Math.min(box + 1, BASE_INTERVALS.length - 1);
|
||||||
|
|
||||||
|
// Calcular novo intervalo
|
||||||
|
if (box === 1) {
|
||||||
|
interval = 1;
|
||||||
|
} else if (box === 2) {
|
||||||
|
interval = 3;
|
||||||
|
} else {
|
||||||
|
// SM-2: I(n) = I(n-1) * EF
|
||||||
|
interval = Math.round(interval * easeFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajustar ease factor baseado na qualidade
|
||||||
|
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
|
||||||
|
easeFactor = easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
||||||
|
easeFactor = Math.max(easeFactor, 1.3); // Mínimo 1.3
|
||||||
|
}
|
||||||
|
|
||||||
|
const due = now + interval * DAY_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...card,
|
||||||
|
box,
|
||||||
|
interval,
|
||||||
|
easeFactor,
|
||||||
|
due,
|
||||||
|
lastSeen: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cria um novo cartão SRS para um chunk */
|
||||||
|
export function createCard(params: {
|
||||||
|
id: string;
|
||||||
|
chunkIt: string;
|
||||||
|
chunkPt: string;
|
||||||
|
audioKey?: string;
|
||||||
|
}): SRSCard {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
chunkIt: params.chunkIt,
|
||||||
|
chunkPt: params.chunkPt,
|
||||||
|
audioKey: params.audioKey,
|
||||||
|
box: 0,
|
||||||
|
interval: 0,
|
||||||
|
due: Date.now(), // Disponível imediatamente
|
||||||
|
lastSeen: 0,
|
||||||
|
easeFactor: 2.5, // Ease factor inicial padrão SM-2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retorna cartões vencidos (due <= agora) */
|
||||||
|
export function getDueCards(cards: SRSCard[]): SRSCard[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return cards.filter((c) => c.due <= now).sort((a, b) => a.due - b.due);
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App.js";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const rootEl = document.getElementById("root");
|
||||||
|
if (!rootEl) throw new Error("Elemento #root não encontrado");
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Service Worker: só em produção para modo resgate offline
|
||||||
|
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/sw.js")
|
||||||
|
.then((reg) => {
|
||||||
|
console.log("[SW] Registrado com sucesso, scope:", reg.scope);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[SW] Falha ao registrar:", err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,434 @@
|
|||||||
|
/* Italiano Vivo — Design System */
|
||||||
|
:root {
|
||||||
|
/* Cores da bandeira italiana + neutros */
|
||||||
|
--verde: #009246;
|
||||||
|
--bianco: #ffffff;
|
||||||
|
--rosso: #ce2b37;
|
||||||
|
--bg: #f8f9fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-muted: #6c757d;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
--radius: 12px;
|
||||||
|
--font: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tipografia */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-it {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botões */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--verde);
|
||||||
|
color: var(--bianco);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #007a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--rosso);
|
||||||
|
color: var(--bianco);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-record {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rosso);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border: 3px solid #ff6b7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-record.recording {
|
||||||
|
animation: pulse 1.2s infinite;
|
||||||
|
background: #ff4757;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--verde);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trail badges */
|
||||||
|
.trail-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trail-bambino { background: #d4edda; color: #155724; }
|
||||||
|
.trail-viaggio { background: #cce5ff; color: #004085; }
|
||||||
|
.trail-vivere { background: #fff3cd; color: #856404; }
|
||||||
|
|
||||||
|
/* Feedback cards */
|
||||||
|
.feedback-axis {
|
||||||
|
padding: 12px;
|
||||||
|
border-left: 4px solid var(--border);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-axis.success { border-left-color: var(--verde); }
|
||||||
|
.feedback-axis.warning { border-left-color: #ffc107; }
|
||||||
|
.feedback-axis.error { border-left-color: var(--rosso); }
|
||||||
|
|
||||||
|
.natural-version {
|
||||||
|
background: #e8f5e9;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natural-version::before {
|
||||||
|
content: "🇮🇹 Um italiano diria: ";
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice booth */
|
||||||
|
.voice-booth {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: linear-gradient(135deg, #f8f9fa, #e8f5e9);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-booth p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grammar errors list */
|
||||||
|
.grammar-error {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-error .fix {
|
||||||
|
color: var(--verde);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-error.blocks_understanding {
|
||||||
|
background: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SRS flashcard */
|
||||||
|
.flashcard {
|
||||||
|
perspective: 1000px;
|
||||||
|
min-height: 200px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-inner {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-inner .it {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--verde);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-inner .pt {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-btn:hover { border-color: var(--verde); color: var(--verde); }
|
||||||
|
.rating-btn.selected { background: var(--verde); color: white; border-color: var(--verde); }
|
||||||
|
|
||||||
|
/* Loading & Error states */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
background: var(--verde);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chunk list */
|
||||||
|
.chunk-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.chunk-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-text .it {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-text .pt {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialogue */
|
||||||
|
.dialogue-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-line.learner {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-line.italian .dialogue-bubble {
|
||||||
|
background: #e8f5e9;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-line.learner .dialogue-bubble {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dissent button */
|
||||||
|
.dissent-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--text-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dissent-btn:hover {
|
||||||
|
border-color: var(--verde);
|
||||||
|
color: var(--verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid var(--verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag .green { background: var(--verde); }
|
||||||
|
.flag .white { background: var(--bianco); border: 1px solid #ddd; }
|
||||||
|
.flag .red { background: var(--rosso); }
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Tipos TypeScript para o conteúdo JSON versionado.
|
||||||
|
* Gerado pelo pipeline em content/, consumido pelo app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Um chunk lexical — unidade mínima de aprendizado */
|
||||||
|
export interface MissionChunk {
|
||||||
|
/** Frase em italiano */
|
||||||
|
it: string;
|
||||||
|
/** Tradução em português */
|
||||||
|
pt: string;
|
||||||
|
/** Nota gramatical ou cultural opcional */
|
||||||
|
note?: string;
|
||||||
|
/** Chave do arquivo de áudio (sem extensão): audio/{audioKey}.mp3 */
|
||||||
|
audioKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declaração can-do do CEFR */
|
||||||
|
export interface CanDo {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cartão de resgate (salvavita) */
|
||||||
|
export interface RescueCard {
|
||||||
|
come_si_dice: string;
|
||||||
|
frase_it: string;
|
||||||
|
explicacao_pt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linha de diálogo */
|
||||||
|
export interface DialogueLine {
|
||||||
|
speaker: "italian" | "learner";
|
||||||
|
it: string;
|
||||||
|
pt: string;
|
||||||
|
audioKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trilhas do app */
|
||||||
|
export type Trail = "bambino" | "viaggio" | "vivere";
|
||||||
|
|
||||||
|
/** Uma missão completa */
|
||||||
|
export interface Mission {
|
||||||
|
id: string;
|
||||||
|
trail: Trail;
|
||||||
|
cefr: string;
|
||||||
|
titleIt: string;
|
||||||
|
titlePt: string;
|
||||||
|
canDos: CanDo[];
|
||||||
|
chunks: MissionChunk[];
|
||||||
|
dialogues: DialogueLine[][];
|
||||||
|
roleplay: {
|
||||||
|
settingIt: string;
|
||||||
|
italianRole: string;
|
||||||
|
goalPt: string;
|
||||||
|
openerIt: string;
|
||||||
|
};
|
||||||
|
survivalChecklist: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bundle de conteúdo versionado */
|
||||||
|
export interface ContentBundle {
|
||||||
|
schemaVersion: 1;
|
||||||
|
missions: Mission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Índice leve carregado na home (sem chunks/diálogos pesados) */
|
||||||
|
export interface MissionSummary {
|
||||||
|
id: string;
|
||||||
|
trail: Trail;
|
||||||
|
cefr: string;
|
||||||
|
titleIt: string;
|
||||||
|
titlePt: string;
|
||||||
|
canDos: CanDo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentIndex {
|
||||||
|
schemaVersion: 1;
|
||||||
|
missions: MissionSummary[];
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/db/db.ts","./src/engine/srs.ts","./src/types/content.ts","./src/app.tsx","./src/main.tsx"],"version":"5.9.3"}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8787",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/audio": {
|
||||||
|
target: "http://localhost:8787",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/content": {
|
||||||
|
target: "http://localhost:8787",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue