Tiomi is an installable, offline-ready Progressive Web App (PWA) that helps learners organize and actively recall knowledge through structured notes, flashcards, and gamified quizzes. Designed for everything from academic study to self-improvement, it brings the best parts of Notion, Quizlet, and Anki โ all in one app.
๐ Live Demo: danielmarkus.web.elte.hu/tetelekzv
- ๐พ Offline-first: Caching and React Query ensure content works even without internet
- ๐ Dashboard: Live animated progress stats with CountUp.js
- ๐ฃ๏ธ Text-to-speech reader with adjustable speed, pitch, and multiple voices; shows reading time per topic
- ๐ฑ Installable as a native-like app on mobile and desktop
- ๐ Light/Dark Mode toggle based on system or user preference
- โ๏ธ Markdown editor with syntax highlighting and custom styling
- ๐ง Flashcard system with spaced repetition and difficulty-based prioritization
- ๐งญ Step-by-step tutorials for key features (modular, handles resize & interaction-required fallback)
- ๐ฎ Gamified quiz engine with streaks, progress %, feedback messages with icons, and optional timer mode
- ๐ฅ Authentication & role-based access:
- Guests: read-only
- Users: create, read, edit
- Superusers: full CRUD access
- Collaborative platform, open content like Wikipedia (future group fragmentation & permissions)
- ๐ Backend rate limiting to prevent misuse with toast notifications
- ๐งช Unit/component testing via ViteTest
- โ๏ธ Minimal PHP backend with custom-built ORM (due to shared hosting)
| Layer | Technologies |
|---|---|
| Frontend | React (Vite), TypeScript, TanStack Router |
| UI & Styling | TailwindCSS, Headless UI, Toastify |
| Data & Schema | React Query, Zod |
| Markdown | react-markdown, rehype-highlight |
| Backend | Vanilla PHP, custom ORM, MySQL |
| Auth | Session-based with role handling |
| Testing | ViteTest |
| PWA Support | vite-plugin-pwa, Service Workers |
Unlike Anki, Quizlet, or Notion, Tiomi is:
- โ๏ธ Focused on structured, rich-markdown notes for academic/technical use
- ๐ง Designed for contextual flashcards linked to content
- ๐ฎ Gamified for active recall via quizzes
- ๐งฉ Built around nested topic organization
- ๐ Fully offline-ready using IndexedDB
- ๐งญ Includes interactive tutorials for onboarding
git clone https://github.com/DraymeM/tetelekzv.git
cd tetelekzvnpm install
npm run devcd BackEnd
php -S localhost:8000โ๏ธ Configure
dev.env.phpandenv.phpwith your DB credentials.
- Installable on mobile/desktop
- Works offline via:
- IndexedDB caching of API responses
- Manual critical resource caching
- Built using vite-plugin-pwa and service workers
Tiomi now includes more robust handling of IndexedDB via idb-keyval:
import { get, set } from "idb-keyval";
// Example: Cache user quiz state
const cacheQuizState = async (key: string, state: QuizState) => {
await set(key, state);
};
const restoreQuizState = async (key: string): Promise<QuizState | undefined> => {
return await get(key);
};-
๐ง Used for persistent quiz progress, flashcard ratings, and user settings.
-
โ๏ธ IndexedDB acts as a local cache layer with fallbacks for offline-first experience.
-
๐ Enables resume-where-you-left-off functionality in offline mode.
-
๐ค Automatic sync when connection is restored (planned).
Tiomiโs TTS leverages the browser-native Web Speech API to read full topics, including main content and nested subsections. This makes it ideal for:
- ๐งโ๐ฆฏ Accessibility (e.g., screen reader support)
- ๐ง Learners with dyslexia
- ๐ง Auditory learners
- Reads long texts (up to ~10,000 characters) by chunking into ~300-character segments, ensuring no words are split.
- Adjustable speed, pitch, and volume controls.
- Supports multiple voices (browser-dependent, e.g., Siri on iOS Safari).
- Displays estimated reading time per topic (~200 words/minute).
- Full playback controls: play, pause, resume, and stop.
- Skip forward and previous chunk navigation to move through text segments.
- Progress bar with draggable seek support, similar to common audio players.
- Mobile-friendly with animations.
- Smooth chunk-based playback to handle very long texts efficiently.
- Improved chunk navigation and enhanced UI options fully implemented.
The core useSpeech hook splits the text into manageable chunks (~300 characters) at whitespace boundaries to avoid splitting words mid-sentence. Playback is queued chunk-by-chunk for smooth long-text reading:
// useSpeech.ts snippet
const speak = (text: string, voiceName?: string, rate = 1, pitch = 1, volume = 1) => {
if (!text || !isSupported || !synthRef.current || isLoadingVoices) {
setError("Cannot speak: Voices are still loading or not supported.");
return;
}
stop(); // reset any ongoing speech
pendingParams.current = { rate, pitch, volume };
const cleanText = text.trim().replace(/\s+/g, " ");
const sentences = cleanText.match(/\S.{0,298}\s/g) || [cleanText]; // chunk by whitespace
console.log(`Total chunks: ${sentences.length}`);
utteranceQueue.current = [
createUtterance(sentences[0] || "", voiceName, rate, pitch, volume),
];
remainingText.current = sentences.slice(1).join("");
currentUtteranceIndex.current = 0;
synthRef.current.cancel();
if (utteranceQueue.current.length > 0) {
synthRef.current.speak(utteranceQueue.current[0]);
}
};The TTS text is prepared by cleaning Markdown content and concatenating all topic sections and subsections into a single string for speech:
// TetelDetails.tsx snippet
const getTextFromMarkdown = (markdown: string) =>
markdown
.replace(/[#_*>\-`]/g, "")
.replace(/$$ .*? $$$$ .*? $$/g, "")
.replace(/!$$ .*? $$$$ .*? $$/g, "")
.replace(/`{1,3}[\s\S]*?`{1,3}/g, "")
.replace(/\s+/g, " ")
.trim();
const textToSpeak = [
getTextFromMarkdown(tetel.name),
...sections.flatMap((section) => [
getTextFromMarkdown(section.content),
...(section.subsections?.flatMap((sub) => [
getTextFromMarkdown(sub.title || ""),
getTextFromMarkdown(sub.description || ""),
]) ?? []),
]),
osszegzes?.content ? "รsszegzรฉs: " + getTextFromMarkdown(osszegzes.content) + " Vรฉge" : "",
]
.filter((text) => text && text.length > 0)
.join(" ")
.trim();
// Usage in JSX
<SpeechController text={textToSpeak} />;- Play/Pause/Resume/Stop: Toggle playback with intuitive buttons.
- Skip Forward/Previous Chunk: Jump between text chunks โ useful for revisiting or skipping parts.
- Timer Display: Shows elapsed and total estimated reading time based on current rate (~200 words/minute).
- Progress Bar with Drag Seek: Drag or click on the progress bar to jump to any chunk.
- Mobile Gesture Support: Swipe down anywhere on the player to stop playback, with smooth fade and slide animations.
- Settings Menu: Lazy-loaded voice selector and sliders for rate, pitch, and volume.
Chunks are processed one at a time to maintain performance with very long texts.
Mobile-specific resume logic handles iOS Safariโs quirks.
The estimated reading time dynamically updates with playback rate changes.
The progress bar and chunk navigation provide a familiar audio player experience.
This architecture and UI provide a robust, accessible, and user-friendly text-to-speech experience designed for deep content consumption and accessibility.
- Counts for:
- ๐ Topics (Tรฉtelek)
- ๐ง Flashcards
- โ Quiz questions
- Animated via CountUp.js
- Responsive Tailwind layout
- System theme detection + manual toggle
- Applies across UI and markdown content
- Create/attempt multiple-choice quizzes
- Tracks progress, scores, and streaks
- Shows progress feedback in % with CountUp.js and encouraging messages + icons
- Optional timer mode for challenge sessions
- Markdown saved in DB
- Rendered with:
- HTML passthrough
- Syntax-highlighted code blocks
- Custom styles
- Perfect for structured, academic content
- Secure, session-based login
- Role permissions:
- Guests โ read-only
- Users โ can create/edit
- Superusers โ full admin CRUD
- ๐ User Registration:
- Email + password sign-up with validation
- Error handling for existing usernames/emails
- ๐ง Email Confirmation:
- Sends a welcome email on successful registration
- Includes styled HTML email with a login button
- Rate each card after answer:
- โ Easy โ delay increases
- โ๏ธ Medium โ shown moderately
- โ Hard โ shown soon again
- 6 difficulty levels (custom scale)
- Linked to topic hierarchy
- Dynamic spaced repetition order
Tiomi features a sidebar navigation that transforms into a footer on mobile devices to ensure easier reach and better usability for mobile users.
- On desktop and larger screens, the sidebar provides quick access to navigation links and the "Vissza" (Back) button.
- On mobile, the sidebar collapses into a footer bar placed at the bottom of the screen, improving thumb accessibility without sacrificing functionality. This design choice enhances navigation ergonomics, making the app more user-friendly across devices.
- Modular, reusable steps for user onboarding
-
- Handles resize edge cases and fallback steps with "requires interaction" flag
- Example setup:
const flashcardTutorialSteps = [
{
title: "Kรกrtyapakli",
content: "Kattints egy kรกrtyรกra a fรณkuszhoz.",
selector: "#card-deck",
},
...
];
// Inside parent:
<CardTutorial
open={showTutorial}
onClose={() => setShowTutorial(false)}
steps={flashcardTutorialSteps}
/>- Built using Zod schemas
- Realtime inline error feedback
- Lightweight vanilla PHP (due to hosting limits)
- Custom mini ORM for DB operations
- Supports
.envconfigs - Rate limiter with toast feedback for end users
The application includes a lightweight custom ORM for structured and reusable database access. It provides a base Model class with common SQL helpers, and each table/model extends this base with custom logic.
๐๏ธ Base Model Class:
<?php
namespace Models;
use \PDO;
use \PDOException;
abstract class Model
{
protected PDO $db;
protected string $table;
public function __construct(PDO $db)
{
$this->db = $db;
if (empty($this->table)) {
throw new \Exception('Model ' . static::class . ' must set protected $table');
}
}
protected function selectOne(string $sql, array $params = []): ?array
{
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row !== false ? $row : null;
}
protected function selectAll(string $sql, array $params = []): array
{
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
protected function execute(string $sql, array $params = []): int
{
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
if (stripos(trim($sql), 'insert') === 0) {
return (int)$this->db->lastInsertId();
}
return $stmt->rowCount();
}
public function delete(string $whereSql, array $params = []): int
{
$sql = "DELETE FROM {$this->table} WHERE $whereSql";
return $this->execute($sql, $params);
}
}๐ง Example: Question Model
This child model extends the base Model class to provide specific logic for handling questions and their related answers. It shows off more advanced usage including:
- Transaction-safe inserts & deletes
- Embedded answer model delegation
- Data transformation
- Pagination, shuffling, and filtering
<?php
namespace Models;
use \PDO;
use \Exception;
class Question extends Model
{
protected string $table = 'questions';
private Answer $answerModel;
public function __construct(PDO $db)
{
parent::__construct($db);
$this->answerModel = new Answer($db);
}
public function findById(int $id): ?array
{
$q = $this->selectOne("SELECT id, question FROM {$this->table} WHERE id = :id", [':id' => $id]);
if (! $q) return null;
$answers = $this->answerModel->findByQuestionId($id);
return [
'id' => (int)$q['id'],
'question' => $q['question'],
'answers' => $answers
];
}
public function findAll(): array
{
$rows = $this->selectAll("SELECT id, question FROM {$this->table}");
return array_map(fn($r) => [
'id' => (int)$r['id'],
'question' => $r['question']
], $rows);
}
public function findRandom(): ?array
{
$q = $this->selectOne("SELECT id, question FROM {$this->table} ORDER BY RAND() LIMIT 1");
if (! $q) return null;
$detail = $this->findById((int)$q['id']);
if ($detail) {
shuffle($detail['answers']);
}
return $detail;
}
public function createWithAnswers(string $text, array $answers, int $tetel_id): int
{
if (count($answers) < 2) {
throw new Exception("At least two answers required");
}
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare("INSERT INTO {$this->table} (question, tetel_id) VALUES (:q, :tid)");
$stmt->execute([':q' => $text, ':tid' => $tetel_id]);
$qid = (int)$this->db->lastInsertId();
$this->answerModel->createBulk($qid, $answers);
$this->db->commit();
return $qid;
} catch (Exception $e) {
$this->db->rollBack();
throw $e;
}
}
public function deleteById(int $id): void
{
$this->db->beginTransaction();
$this->answerModel->deleteByQuestionId($id);
$this->delete("id = :id", [':id' => $id]);
$this->db->commit();
}
public function updateWithAnswers(int $id, string $text, array $answers): void
{
if (count($answers) < 2) {
throw new Exception("At least two answers required");
}
$this->db->beginTransaction();
$this->execute("UPDATE {$this->table} SET question = :q WHERE id = :id", [':q' => $text, ':id' => $id]);
$this->answerModel->updateBulk($id, $answers);
$this->db->commit();
}
}This API endpoint uses the Question model to return a paginated list of multiple-choice questions. It uses the ORMโs selectAll() helper internally via raw SQL, and maps the results for frontend use.
<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Methods: GET");
$pdo = require __DIR__ . '/../../core/init.php';
require_once __DIR__ . '/../../models/Model.php';
require_once __DIR__ . '/../../models/Answer.php';
require_once __DIR__ . '/../../models/Question.php';
use Models\Question;
// Parse pagination parameters
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$limit = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 35;
$offset = ($page - 1) * $limit;
// Instantiate the model
$questionModel = new Question($pdo);
// Total count for pagination UI
$total = $pdo->query("SELECT COUNT(*) FROM questions")->fetchColumn();
// Fetch paginated questions
$stmt = $pdo->prepare("SELECT id, question FROM questions LIMIT :limit OFFSET :offset");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Return formatted response
echo json_encode([
"data" => array_map(fn($q) => [
'id' => (int)$q['id'],
'question' => $q['question']
], $questions),
"total" => (int)$total,
]);- Unit/component testing via ViteTest
- Covers:
- Forms
- Zod schemas
- UI components and logic
<Disclosure>
<Disclosure.Button>
<FaChevronDown />
</Disclosure.Button>
<Disclosure.Panel>
{section.subsections.map(...)}
</Disclosure.Panel>
</Disclosure>- ๐ Organize study materials into nested topics
- ๐งช Self-test with flashcards & quizzes
- โ๏ธ Markdown-powered note-taking
- Ideal for structured learning with:
- Linked flashcards
- Active recall testing
๐ฑ Mobile Lighthouse Scores
| Page | Performance โก | Accessibility โฟ | Best Practices โ | SEO ๐ |
|---|---|---|---|---|
| Home Page | 95 | 98 | 100 | 100 |
| Topic Detail | 95 | 100 | 100 | 100 |
| Quiz Game | 98 | 100 | 100 | 100 |
| FlashCardGame | 98 | 100 | 100 | 100 |
| Topic List | 98 | 100 | 100 | 100 |
| Topic LandingPage | 97 | 100 | 100 | 100 |
| Random Flashcards | 98 | 100 | 100 | 100 |
๐ป Desktop Lighthouse Scores
| Page | Performance โก | Accessibility โฟ | Best Practices โ | SEO ๐ |
|---|---|---|---|---|
| Home Page | 100 | 98 | 100 | 100 |
| Topic Detail | 100 | 100 | 100 | 100 |
| Quiz Game | 100 | 100 | 100 | 100 |
| FlashCardGame | 100 | 100 | 100 | 100 |
| Topic List | 100 | 100 | 100 | 100 |
| Topic LandingPage | 100 | 100 | 100 | 100 |
| Random Flashcards | 100 | 100 | 100 | 100 |
- โ Lighthouse: 92โ100 across categories
- โก Fast, responsive, SEO-optimized
- ๐ฆ Full PWA compliance (installable, background updates)
- Migrate backend to a more scalable stack:
- โณ๏ธ Slim PHP / Lumen (Laravel) / Express.js / Actix
- Group feature with their own admins own permissions
- Offline sync actions when connectivity is back
- Quiz questions and Flashcards are tied to topics (tรฉtelek) mainly help learning specific topics
- Open collaboration model currently; planned group fragmentation for better access control
- Tutorial engine now handles resize edge cases and supports fallback steps requiring user interaction before continuing
- Card game enhancements improve engagement and learning effectiveness
- Text-to-speech scaffolds all available voices with speed and pitch adjustment, making it ideal for diverse learners
- Reading time estimates help learners manage study sessions effectively
