|
|
|
|
|
|
|
|
|
|
|
import json |
|
|
from pathlib import Path |
|
|
from typing import Dict, List |
|
|
|
|
|
from .config import get_user_dir |
|
|
from .flashcards_tools import load_deck |
|
|
|
|
|
|
|
|
|
|
|
def _build_flipbook_html(deck_name: str, cards: List[Dict]) -> str: |
|
|
""" |
|
|
Builds a simple HTML+JS flip-style viewer for a deck of cards. |
|
|
Front = card['front'], Back = card['back']. |
|
|
""" |
|
|
js_cards = json.dumps( |
|
|
[ |
|
|
{"front": c.get("front", ""), "back": c.get("back", "")} |
|
|
for c in cards |
|
|
], |
|
|
ensure_ascii=False, |
|
|
) |
|
|
|
|
|
html = f"""<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<title>Flashcards — {deck_name}</title> |
|
|
<style> |
|
|
body {{ |
|
|
background: #111; |
|
|
color: #eee; |
|
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
|
|
padding: 1.5rem; |
|
|
}} |
|
|
|
|
|
.wrapper {{ |
|
|
max-width: 700px; |
|
|
margin: 0 auto; |
|
|
}} |
|
|
|
|
|
h1 {{ |
|
|
text-align: center; |
|
|
margin-bottom: 1rem; |
|
|
}} |
|
|
|
|
|
.card-container {{ |
|
|
perspective: 1000px; |
|
|
margin-bottom: 1rem; |
|
|
}} |
|
|
|
|
|
.card {{ |
|
|
position: relative; |
|
|
width: 100%; |
|
|
height: 250px; |
|
|
border-radius: 16px; |
|
|
background: #222; |
|
|
box-shadow: 0 8px 15px rgba(0,0,0,0.4); |
|
|
transition: transform 0.6s; |
|
|
transform-style: preserve-3d; |
|
|
cursor: pointer; |
|
|
}} |
|
|
|
|
|
.card.flipped {{ |
|
|
transform: rotateY(180deg); |
|
|
}} |
|
|
|
|
|
.card-face {{ |
|
|
position: absolute; |
|
|
inset: 0; |
|
|
border-radius: 16px; |
|
|
backface-visibility: hidden; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
padding: 1rem; |
|
|
}} |
|
|
|
|
|
.card-face.front {{ |
|
|
background: #333; |
|
|
}} |
|
|
|
|
|
.card-face.back {{ |
|
|
background: #1a73e8; |
|
|
transform: rotateY(180deg); |
|
|
}} |
|
|
|
|
|
.card-text {{ |
|
|
font-size: 2.2rem; |
|
|
text-align: center; |
|
|
word-wrap: break-word; |
|
|
}} |
|
|
|
|
|
.controls {{ |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
gap: 0.75rem; |
|
|
margin-bottom: 0.5rem; |
|
|
}} |
|
|
|
|
|
button {{ |
|
|
background: #333; |
|
|
color: #eee; |
|
|
border-radius: 999px; |
|
|
border: none; |
|
|
padding: 0.5rem 1rem; |
|
|
font-size: 0.95rem; |
|
|
cursor: pointer; |
|
|
transition: background 0.2s ease, transform 0.1s ease; |
|
|
}} |
|
|
|
|
|
button:hover {{ |
|
|
background: #444; |
|
|
transform: translateY(-1px); |
|
|
}} |
|
|
|
|
|
.meta {{ |
|
|
text-align: center; |
|
|
margin-top: 0.25rem; |
|
|
font-size: 0.9rem; |
|
|
color: #ccc; |
|
|
}} |
|
|
|
|
|
.badge {{ |
|
|
display: inline-block; |
|
|
padding: 0.1rem 0.75rem; |
|
|
border-radius: 999px; |
|
|
font-size: 0.8rem; |
|
|
background: #555; |
|
|
margin-left: 0.5rem; |
|
|
}} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="wrapper"> |
|
|
<h1>{deck_name}</h1> |
|
|
<div class="card-container"> |
|
|
<div class="card" id="card"> |
|
|
<div class="card-face front"> |
|
|
<div class="card-text" id="cardFront"></div> |
|
|
</div> |
|
|
<div class="card-face back"> |
|
|
<div class="card-text" id="cardBack"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="controls"> |
|
|
<button id="prevBtn">⏮ Prev</button> |
|
|
<button id="flipBtn">🔁 Flip</button> |
|
|
<button id="nextBtn">Next ⏭</button> |
|
|
<button id="shuffleBtn">🔀 Shuffle</button> |
|
|
</div> |
|
|
|
|
|
<div class="meta"> |
|
|
<span id="cardIndex">Card 0 / 0</span> |
|
|
<span class="badge" id="sideLabel">Front</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const cards = {js_cards}; |
|
|
let currentIndex = 0; |
|
|
let isFlipped = false; |
|
|
|
|
|
const cardEl = document.getElementById('card'); |
|
|
const frontEl = document.getElementById('cardFront'); |
|
|
const backEl = document.getElementById('cardBack'); |
|
|
const indexEl = document.getElementById('cardIndex'); |
|
|
const sideLabelEl = document.getElementById('sideLabel'); |
|
|
|
|
|
function renderCard() {{ |
|
|
if (!cards.length) {{ |
|
|
frontEl.textContent = "(No cards)"; |
|
|
backEl.textContent = ""; |
|
|
indexEl.textContent = "Card 0 / 0"; |
|
|
sideLabelEl.textContent = "Front"; |
|
|
cardEl.classList.remove('flipped'); |
|
|
return; |
|
|
}} |
|
|
|
|
|
const card = cards[currentIndex]; |
|
|
frontEl.textContent = card.front || ""; |
|
|
backEl.textContent = card.back || ""; |
|
|
|
|
|
indexEl.textContent = "Card " + (currentIndex + 1) + " / " + cards.length; |
|
|
sideLabelEl.textContent = isFlipped ? "Back" : "Front"; |
|
|
}} |
|
|
|
|
|
function flipCard() {{ |
|
|
if (!cards.length) return; |
|
|
isFlipped = !isFlipped; |
|
|
if (isFlipped) {{ |
|
|
cardEl.classList.add('flipped'); |
|
|
}} else {{ |
|
|
cardEl.classList.remove('flipped'); |
|
|
}} |
|
|
sideLabelEl.textContent = isFlipped ? "Back" : "Front"; |
|
|
}} |
|
|
|
|
|
function nextCard() {{ |
|
|
if (!cards.length) return; |
|
|
currentIndex = (currentIndex + 1) % cards.length; |
|
|
isFlipped = false; |
|
|
cardEl.classList.remove('flipped'); |
|
|
renderCard(); |
|
|
}} |
|
|
|
|
|
function prevCard() {{ |
|
|
if (!cards.length) return; |
|
|
currentIndex = (currentIndex - 1 + cards.length) % cards.length; |
|
|
isFlipped = false; |
|
|
cardEl.classList.remove('flipped'); |
|
|
renderCard(); |
|
|
}} |
|
|
|
|
|
function shuffleCards() {{ |
|
|
for (let i = cards.length - 1; i > 0; i--) {{ |
|
|
const j = Math.floor(Math.random() * (i + 1)); |
|
|
[cards[i], cards[j]] = [cards[j], cards[i]]; |
|
|
}} |
|
|
currentIndex = 0; |
|
|
isFlipped = false; |
|
|
cardEl.classList.remove('flipped'); |
|
|
renderCard(); |
|
|
}} |
|
|
|
|
|
cardEl.addEventListener('click', flipCard); |
|
|
document.getElementById('flipBtn').addEventListener('click', flipCard); |
|
|
document.getElementById('nextBtn').addEventListener('click', nextCard); |
|
|
document.getElementById('prevBtn').addEventListener('click', prevCard); |
|
|
document.getElementById('shuffleBtn').addEventListener('click', shuffleCards); |
|
|
|
|
|
document.addEventListener('keydown', (e) => {{ |
|
|
if (!cards.length) return; |
|
|
if (e.code === "ArrowRight") nextCard(); |
|
|
else if (e.code === "ArrowLeft") prevCard(); |
|
|
else if (e.code === "Space") {{ |
|
|
e.preventDefault(); |
|
|
flipCard(); |
|
|
}} |
|
|
}}); |
|
|
|
|
|
renderCard(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
return html |
|
|
|
|
|
|
|
|
def generate_flashcard_viewer_for_user(username: str, deck_path: Path) -> Path: |
|
|
""" |
|
|
Generates an HTML flipbook viewer for the given deck in the user's |
|
|
/viewers directory, and returns the path to the HTML file. |
|
|
""" |
|
|
deck = load_deck(deck_path) |
|
|
deck_name = deck.get("name", deck_path.stem) |
|
|
cards = deck.get("cards", []) |
|
|
|
|
|
html_str = _build_flipbook_html(deck_name, cards) |
|
|
|
|
|
user_dir = get_user_dir(username) |
|
|
viewer_dir = user_dir / "viewers" |
|
|
viewer_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
safe_name = deck_path.stem |
|
|
out_path = viewer_dir / f"{safe_name}_viewer.html" |
|
|
out_path.write_text(html_str, encoding="utf-8") |
|
|
return out_path |
|
|
|
|
|
|