Select decks above and click Start to begin your session.
Create button on any template to build a single card.
Enable AI Bulk Builder to generate cards across multiple templates.
Choose a deck from the sidebar to view and manage its cards.
Track your learning progress and review forecasts
New accounts start with an interactive onboarding tour that walks through Library, Create, Study, and Settings. You can replay the tour anytime by refreshing the page after logging in.
The left sidebar has six sections: Library (decks and cards), Create (template builders and AI bulk generation), Study (review queue), Stats (progress charts), Settings (preferences and admin), and Help (this page). The sidebar collapses on smaller screens.
Decks are shown as a responsive grid of cards. Each deck card displays the name, total card count, due count badge, and last-studied time. Sub-decks are visually nested under their parent.
Click a deck to open its detail view. Inside you can study the deck, add cards, and manage all cards in a sortable table with search, template filter, and status filter. Click any card to preview it in a modal.
Use the Active toggle on cards to deactivate them. Inactive cards are excluded from study queues and stats but remain visible for reference.
Templates are displayed as a responsive grid of cards with icons and descriptions. Click a template once to open the single-card builder. Select multiple templates (2 or more) to unlock the AI Bulk Card Creator.
Enable Developer Mode in Settings to reveal the Upload Template button for importing custom templates.
On the Study page, deck tiles show total cards and due counts (new / learning / review). Select one or more decks and click Start Session. The app builds a prioritized queue using FSRS scheduling:
Rate each card Again, Hard, Good, or Easy. FSRS schedules the next review automatically. When the session ends, a results summary appears and confetti celebrates your progress.
Click End Session anytime to stop early. Your progress up to that point is saved.
Twelve built-in templates are included:
{{double braces}} in a sentence.[[double brackets]].Every template supports images, audio, TTS generation, AI grading, and AI feedback.
When 2+ templates are selected, the bulk creator appears. Describe your study material (or upload images, documents, or audio) and the AI will:
Pick a model tier (Pro/Smart/Fast) and card count (Auto or fixed 5–50). Each template type has built-in optimized settings for AI feedback, grading, and TTS.
Templates can use "__tts__" as a marker in audio fields (e.g. "frontAudio": "__tts__") to trigger automatic text-to-speech generation during bulk creation.
After generation, review the cards, uncheck any you don't want, then Save All or Save Selected.
Control learning order with prerequisites:
Cards with unmet prerequisites are hidden from the study queue until their prerequisites are satisfied.
The Stats page shows review counts, accuracy, cards studied per day, and a 7-day due-card forecast. Chart.js powers the visualizations:
Accuracy metrics only count active cards.
Study — Adjust desired retention (70–99%, default 90%). Higher retention = more frequent reviews. Toggle Auto-generate embeddings to enable semantic search (slows down card creation; off by default).
Appearance — ProjectCards uses a playful pastel Shibuya-kei style with rounded cards, thick borders, and soft colors.
Usage — View storage usage with a color-coded progress bar and daily rate-limit usage per AI category.
Account — Display email, log out, or reset all review history and card states.
Admin Panel — Admins can view all users, manage admin status, delete users, and edit global rate limits.
Advanced — Developer Mode reveals the Docs tab and the Upload Template button in the Create page.
Templates live in Card_Templates/ (built-in) or cards/users/{user_id}/templates/ (uploaded, persistent). Each template is a self-contained folder:
MyTemplate/
template.json
schema.json
viewer/
index.html
style.css
logic.js
builder/
index.html
style.css
logic.js
Required. Minimal metadata:
{
"name": "My Template",
"description": "What this template does",
"version": "1.0"
}
Required for AI Bulk Card Creator compatibility. Describes your template's data fields so the AI knows how to generate cards for it. If missing, the app auto-generates a minimal schema, but explicit schemas produce much better results.
{
"name": "MyTemplate",
"description": "One-sentence description of what this template does.",
"data_schema": {
"front": {
"type": "string",
"required": true,
"description": "Question or prompt shown on the front"
},
"back": {
"type": "string",
"required": true,
"description": "Answer shown on the back"
},
"frontImage": {
"type": "string",
"required": false,
"description": "URL to an image displayed on the front"
},
"frontAudio": {
"type": "string",
"required": false,
"description": "URL to an audio file played on the front"
}
},
"example": {
"front": "What is the capital of France?",
"back": "Paris"
}
}
Field types: string, number, boolean, array, object. Use required: true for fields the AI must always generate. Use required: false for optional media or extras. The example object is shown to the AI as a concrete sample.
The viewer loads in an iframe during study. It receives card data from the parent and sends back ratings.
{type: 'card', data: {...}, previews: {...}, theme: 'dark', preview: false} — sent when a new card loads. data contains the card object with id, data (your template fields), and config. previews shows interval hints like {again: '1m', good: '3d'}. theme is 'dark' or 'light'. preview is true when shown in the card preview modal.{type: 'theme', theme: 'dark'} — sent live when the user toggles dark mode.{type: 'ready'} — send when your viewer loads so the parent knows to send the card. Wrap in DOMContentLoaded.{type: 'rating', value: 'Again'} — send when the user picks a rating. Valid values: Again, Hard, Good, Easy.{type: 'resize', height: 420} — send when your content height changes so the parent can resize the iframe. Use a ResizeObserver on document.body or the root element.The parent sends the theme via postMessage. Your template should apply a dark-mode class to body:
function applyTheme(theme) {
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
// Init from localStorage on load
(function initTheme() {
const saved = localStorage.getItem('theme');
if (saved === 'dark') document.body.classList.add('dark-mode');
})();
// Listen for live updates
window.addEventListener('message', (e) => {
if (e.data?.type === 'theme') applyTheme(e.data.theme);
if (e.data?.type === 'card') {
if (e.data.theme) applyTheme(e.data.theme);
// ... render card ...
}
});
Add body.dark-mode CSS overrides in style.css. See built-in templates for palette examples (background: #0d1117, text: #c9d1d9, surface: #161b22, border: #30363d).
You can also import the shared design tokens: <link rel="stylesheet" href="/templates/_shared/tokens.css">
The parent sends preview: true when showing a card in the preview modal. Hide rating buttons and input areas:
<style>
body.preview-mode #rating-bar,
body.preview-mode #input-area { display: none !important; }
</style>
The parent sets the iframe height dynamically. Tell it when you resize:
function sendHeight() {
const h = document.documentElement.scrollHeight;
window.parent.postMessage({type: 'resize', height: h}, '*');
}
// Send on load and when content changes
window.addEventListener('load', sendHeight);
new ResizeObserver(sendHeight).observe(document.body);
Upload files via POST /api/upload and store the returned URL in card data:
async function uploadMedia(file) {
const form = new FormData();
form.append('file', file);
const res = await apiFetch('/api/upload', { method: 'POST', body: form });
const result = await res.json();
return result.url; // e.g. "/api/media/filename.jpg"
}
Reference them in your viewer with <img src="${card.data.frontImage}"> or <audio controls src="${card.data.frontAudio}">. The parent serves them from /api/media/{filename}.
The card object includes config (set by the builder or AI Bulk Creator). Use it to conditionally show/hide features:
window.addEventListener('message', (e) => {
if (e.data?.type === 'card') {
const card = e.data.data;
const cfg = card.config || {};
// Templates have built-in input methods and AI settings.
// Mic button is available on text-input templates for voice transcription.
}
});
The builder loads in an iframe when the user clicks a template in the Create page. It creates and edits cards.
async function saveCard() {
const deck = new URLSearchParams(location.search).get('deck') || 'default';
const res = await apiFetch('/api/cards', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
template: 'MyTemplate',
deck: deck,
data: { front: '...', back: '...' },
config: {}
})
});
const result = await res.json();
window.parent.postMessage({type: 'saved', id: result.id}, '*');
}
The builder receives an edit message when editing an existing card:
window.addEventListener('message', (e) => {
if (e.data?.type === 'edit') {
const card = e.data.data;
// Pre-populate your form fields
document.getElementById('front').value = card.data.front;
document.getElementById('back').value = card.data.back;
// Store the ID for the update call
editingId = card.id;
}
});
async function updateCard() {
const res = await fetch(`/api/cards/${editingId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
data: { front: '...', back: '...' },
config: {}
})
});
window.parent.postMessage({type: 'updated', id: editingId}, '*');
}
{type: 'theme', theme: 'dark'} — dark mode toggle.{type: 'edit', data: {...}} — populate form with existing card data.{type: 'saved', id: '...'} — new card created successfully.{type: 'updated', id: '...'} — existing card updated successfully.{type: 'resize', height: 600} — builder iframe height changed.Templates have built-in optimized settings for AI feedback, grading, and TTS. The config field can still be used for template-specific options.
Templates can use icons from the /Icons/ folder. Reference them with absolute paths like /Icons/icons8-name-50.png. Both 50px and 100px versions exist for every icon.
Include the shared library to use AI from any template:
<script src="/templates/_shared/openrouter.js"></script>
// Simple prompt
const reply = await OR.ask("Explain recursion", "smart");
// Full control
const result = await OR.chat({
capability: "text", // "text" | "multimodal"
tier: "smart", // "pro" | "smart" | "fast" | "auto"
messages: [{role: "user", content: "Hello"}],
temperature: 0.7,
max_tokens: 2048,
response_format: {type: "json_object"}
});
// result.text — the reply
// result.model — the model used
const reply = await OR.askMultimodal([
{role: "user", content: [
{type: "text", text: "What's in this image?"},
{type: "image_url", image_url: {url: "data:image/png;base64,..."}}
]}
], "smart");
const result = await OR.gradeAnswer(
question, // the card front / prompt
correctAnswer, // the expected answer
userAnswer, // what the user typed
"smart" // tier
);
// result.grade // "A" | "B" | "C" | "D" | "F"
// result.score // 0-100
// result.feedback // text feedback
// result.suggestedRating // "Again" | "Hard" | "Good" | "Easy"
Sends audio directly to a multimodal model. No transcription step.
// Record mic and grade in one call
const result = await OR.recordAndGradeSpeech(
question, correctAnswer, 10000, "smart"
);
// Or grade existing base64 audio
const result = await OR.gradeSpeech(
question, correctAnswer, base64Audio, "webm", "smart"
);
const audio = await OR.ttsAudio("Hello world", "smart");
audio.play();
In bulk generation, use "__tts__" as an audio field value to auto-generate TTS for that field.
const result = await OR.stt(base64Audio, "webm");
console.log(result.text);
const cards = await OR.generateCards("Python decorators", "smart", 3);
// cards = [{front: "...", back: "..."}, ...]
const result = await OR.embeddings(["hello", "world"]);
// result.vectors = [[0.1, ...], [0.2, ...]]
const result = await OR.generateImage("A cat in space", "smart");
// result.image_url or result.text
| Capability | Pro | Smart | Fast | Auto |
|---|---|---|---|---|
| Text | deepseek/deepseek-v4-pro | deepseek/deepseek-v4-flash | openai/gpt-oss-20b | — |
| Multimodal | google/gemini-pro-latest | google/gemini-flash-latest | google/gemini-3.1-flash-lite-preview | openrouter/auto |
| TTS | google/gemini-3.1-flash-tts-preview | hexgrad/kokoro-82m | — | — |
| Image | google/gemini-3.1-flash-image-preview | bytedance-seed/seedream-4.5 | — | — |
Endpoints you may call from builders and viewers:
GET /api/cards # List all cards
GET /api/cards/{id} # Get a single card
POST /api/cards # Create a card
POST /api/cards/batch # Batch create cards (body: {cards: [...]})
PUT /api/cards/{id} # Update card data/config
DELETE /api/cards/{id} # Delete a card
POST /api/cards/{id}/review # Submit review (body: {rating: "Again"})
POST /api/cards/{id}/move # Move card to another deck
POST /api/cards/{id}/reset # Reset FSRS state to New
POST /api/cards/{id}/active # Toggle active (body: {active: true|false})
PUT /api/cards/{id}/prerequisites # Set card prerequisites
POST /api/cards/bulk-generate # AI bulk generation (SSE streaming)
POST /api/cards/backfill-embeddings # Generate missing embeddings
GET /api/decks # List all decks with stats
POST /api/decks # Create a new deck
PUT /api/decks/{id} # Rename deck (body: {name: "..."})
PUT /api/decks/{id}/prerequisites # Set deck prerequisites
DELETE /api/decks/{id} # Delete deck (?cascade=true)
POST /api/decks/{id}/reset # Reset FSRS for all cards in deck
GET /api/study-queue # Get due cards (?deck= or ?decks=a,b)
GET /api/retention # Get desired retention
POST /api/retention # Set desired retention
GET /api/embeddings-setting # Get embedding generation setting
POST /api/embeddings-setting # Set embedding generation
GET /api/templates # List all templates + protected set
GET /api/templates/{name}/schema # Get schema.json
POST /api/templates/analyze # AI security scan (multipart)
POST /api/templates # Save uploaded template (multipart)
DELETE /api/templates/{name} # Delete custom template
POST /api/upload # Upload file (multipart, field: "file")
GET /api/media/{filename} # Serve uploaded file
GET /api/user/storage # Storage usage
GET /api/user/limits # Rate limit usage
GET /api/admin/users # List all users (admin only)
POST /api/admin/users/{id}/toggle-admin
DELETE /api/admin/users/{id} # Delete user and all data
GET /api/admin/usage # Daily usage report
GET /api/admin/rate-limits # Get rate limits
POST /api/admin/rate-limits # Update rate limits
When a user rates a card, FSRS updates its state and schedules the next review. The viewer doesn't handle this — just send the rating.
The user can adjust desired retention in Settings (default 90%). This changes how aggressively FSRS schedules reviews. Your template does not need to handle this — it happens automatically in the backend scheduler.
A minimal template with dark mode, preview support, and resizing.
<style>
body { font-family: sans-serif; padding: 20px; text-align: center; background: #fafafa; }
body.dark-mode { background: #0d1117; color: #c9d1d9; }
#card { background: #ffffff; border: 1px solid #ccc; padding: 40px; margin-bottom: 20px; cursor: pointer; border-radius: 8px; }
body.dark-mode #card { background: #161b22; border-color: #30363d; }
.btns { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
button { padding: 10px 20px; border: 1px solid #ccc; background: #ffffff; cursor: pointer; border-radius: 4px; }
body.preview-mode .btns { display: none !important; }
</style>
<div id="card" onclick="flip()">Waiting...</div>
<div class="btns">
<button onclick="rate('Again')">Again</button>
<button onclick="rate('Hard')">Hard</button>
<button onclick="rate('Good')">Good</button>
<button onclick="rate('Easy')">Easy</button>
</div>
<script>
let card = null, showingBack = false;
function applyTheme(theme) {
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
(function initTheme() {
const saved = localStorage.getItem('theme');
if (saved === 'dark') document.body.classList.add('dark-mode');
})();
function sendHeight() {
window.parent.postMessage({type: 'resize', height: document.documentElement.scrollHeight}, '*');
}
window.addEventListener('load', sendHeight);
new ResizeObserver(sendHeight).observe(document.body);
document.addEventListener('DOMContentLoaded', () => {
window.parent.postMessage({type: 'ready'}, '*');
});
window.addEventListener('message', (e) => {
if (e.data?.type === 'theme') applyTheme(e.data.theme);
if (e.data?.type === 'card') {
card = e.data.data;
if (e.data.theme) applyTheme(e.data.theme);
if (e.data.preview) document.body.classList.add('preview-mode');
else document.body.classList.remove('preview-mode');
showingBack = false;
render();
}
});
function render() {
document.getElementById('card').textContent =
showingBack ? card.data.back : card.data.front;
}
function flip() { if (card) { showingBack = !showingBack; render(); } }
function rate(v) { window.parent.postMessage({type: 'rating', value: v}, '*'); }
</script>
<style>
body { font-family: sans-serif; padding: 20px; max-width: 400px; margin: 0 auto; background: #fafafa; }
body.dark-mode { background: #0d1117; color: #c9d1d9; }
input, textarea { width: 100%; padding: 8px; margin-bottom: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
body.dark-mode input, body.dark-mode textarea { background: #161b22; border-color: #30363d; color: #c9d1d9; }
button { padding: 10px 20px; cursor: pointer; border-radius: 4px; }
</style>
<input id="f" placeholder="Front">
<textarea id="b" placeholder="Back"></textarea>
<button onclick="save()">Save</button>
<script>
function getDeck() {
return new URLSearchParams(location.search).get('deck') || 'default';
}
function applyTheme(theme) {
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
(function initTheme() {
const saved = localStorage.getItem('theme');
if (saved === 'dark') document.body.classList.add('dark-mode');
})();
window.addEventListener('message', (e) => {
if (e.data?.type === 'theme') applyTheme(e.data.theme);
if (e.data?.type === 'edit') {
document.getElementById('f').value = e.data.data.data.front || '';
document.getElementById('b').value = e.data.data.data.back || '';
editingId = e.data.data.id;
}
});
let editingId = null;
async function save() {
const payload = {
template: 'Minimal',
deck: getDeck(),
data: { front: f.value, back: b.value },
config: {}
};
const url = editingId ? `/api/cards/${editingId}` : '/api/cards';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const r = await res.json();
window.parent.postMessage({type: editingId ? 'updated' : 'saved', id: r.id || editingId}, '*');
f.value = ''; b.value = ''; editingId = null;
}
</script>
/templates/_shared/openrouter.js for shared resources.?deck=my-deck.<textarea> or <input>.postMessage({type: 'ready'}) in DOMContentLoaded so the parent iframe is fully ready.{type: 'resize', height} whenever your content changes so the iframe never clips.Customize your study experience and account preferences