Skip to content

Commit 6f232a1

Browse files
feat: Phase 1 — PWA, dark mode, search history, direction toggle, SQLite filelock
Backend: - Fix ensure_db() race condition: wrap entire rebuild in FileLock so concurrent workers on startup don't corrupt the SQLite database. WEB_CONCURRENCY=1 workaround on Render can now be removed. - Add filelock>=3.13.0 to requirements.txt Frontend: - PWA: manifest.json with Monaco red theme, SVG icons, app shortcuts, service worker (sw.js) with network-first caching, install prompt banner (dismissed state persisted in localStorage) - Dark mode: class-based via Tailwind v4 @custom-variant, ThemeProvider context, sun/moon toggle in nav, system preference respected on first load, anti-FOUC inline script in <head>, preference saved to localStorage - Search history: last 8 queries stored in localStorage, shown as chips when input is empty, clearable - Direction toggle: FR→Monégasque / Monégasque→FR button next to search input, swaps primary/secondary display in WordCard results - Typography: Lora serif font for h1/h2/h3 to give dictionary character, Geist stays as body font - All components updated with dark: Tailwind variants
1 parent 3bc600e commit 6f232a1

15 files changed

Lines changed: 486 additions & 116 deletions

File tree

backend/app/database/sqlite.py

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import sqlite3
33
import time
44

5+
from filelock import FileLock
6+
57
from app.config import settings
68

79

@@ -74,53 +76,55 @@ def _iter_entries():
7476

7577
def ensure_db() -> None:
7678
os.makedirs(os.path.dirname(os.path.abspath(settings.sqlite_path)), exist_ok=True)
77-
conn = sqlite3.connect(settings.sqlite_path)
78-
try:
79-
conn.execute("PRAGMA journal_mode=WAL")
79+
lock_path = settings.sqlite_path + ".lock"
80+
with FileLock(lock_path, timeout=60):
81+
conn = sqlite3.connect(settings.sqlite_path)
8082
try:
81-
sig = conn.execute(
82-
"SELECT value FROM cache_metadata WHERE key = 'source_signature'"
83-
).fetchone()
84-
count = conn.execute("SELECT COUNT(*) FROM dictionary").fetchone()[0]
85-
if sig and sig[0] == _sql_signature() and count > 0:
86-
return
87-
except sqlite3.Error:
88-
pass
83+
conn.execute("PRAGMA journal_mode=WAL")
84+
try:
85+
sig = conn.execute(
86+
"SELECT value FROM cache_metadata WHERE key = 'source_signature'"
87+
).fetchone()
88+
count = conn.execute("SELECT COUNT(*) FROM dictionary").fetchone()[0]
89+
if sig and sig[0] == _sql_signature() and count > 0:
90+
return
91+
except sqlite3.Error:
92+
pass
8993

90-
conn.executescript("""
91-
DROP TABLE IF EXISTS dictionary;
92-
DROP TABLE IF EXISTS cache_metadata;
93-
DROP TABLE IF EXISTS ai_response_cache;
94-
CREATE TABLE dictionary (
95-
id INTEGER PRIMARY KEY,
96-
word TEXT,
97-
definition TEXT
98-
);
99-
CREATE TABLE cache_metadata (
100-
key TEXT PRIMARY KEY,
101-
value TEXT NOT NULL
102-
);
103-
CREATE TABLE ai_response_cache (
104-
cache_key TEXT PRIMARY KEY,
105-
response_json TEXT NOT NULL,
106-
created_at INTEGER NOT NULL
107-
);
108-
""")
109-
conn.executemany(
110-
"INSERT INTO dictionary (id, word, definition) VALUES (?, ?, ?)",
111-
_iter_entries(),
112-
)
113-
conn.execute("CREATE INDEX idx_word ON dictionary(word)")
114-
conn.execute("CREATE INDEX idx_def ON dictionary(definition)")
115-
count = conn.execute("SELECT COUNT(*) FROM dictionary").fetchone()[0]
116-
conn.executemany(
117-
"INSERT INTO cache_metadata (key, value) VALUES (?, ?)",
118-
{
119-
"source_signature": _sql_signature(),
120-
"row_count": str(count),
121-
"rebuilt_at": str(int(time.time())),
122-
}.items(),
123-
)
124-
conn.commit()
125-
finally:
126-
conn.close()
94+
conn.executescript("""
95+
DROP TABLE IF EXISTS dictionary;
96+
DROP TABLE IF EXISTS cache_metadata;
97+
DROP TABLE IF EXISTS ai_response_cache;
98+
CREATE TABLE dictionary (
99+
id INTEGER PRIMARY KEY,
100+
word TEXT,
101+
definition TEXT
102+
);
103+
CREATE TABLE cache_metadata (
104+
key TEXT PRIMARY KEY,
105+
value TEXT NOT NULL
106+
);
107+
CREATE TABLE ai_response_cache (
108+
cache_key TEXT PRIMARY KEY,
109+
response_json TEXT NOT NULL,
110+
created_at INTEGER NOT NULL
111+
);
112+
""")
113+
conn.executemany(
114+
"INSERT INTO dictionary (id, word, definition) VALUES (?, ?, ?)",
115+
_iter_entries(),
116+
)
117+
conn.execute("CREATE INDEX idx_word ON dictionary(word)")
118+
conn.execute("CREATE INDEX idx_def ON dictionary(definition)")
119+
count = conn.execute("SELECT COUNT(*) FROM dictionary").fetchone()[0]
120+
conn.executemany(
121+
"INSERT INTO cache_metadata (key, value) VALUES (?, ?)",
122+
{
123+
"source_signature": _sql_signature(),
124+
"row_count": str(count),
125+
"rebuilt_at": str(int(time.time())),
126+
}.items(),
127+
)
128+
conn.commit()
129+
finally:
130+
conn.close()

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ gunicorn>=21.2.0
44
pydantic-settings>=2.5.0
55
google-genai>=1.0.0
66
python-dotenv>=1.0.0
7+
filelock>=3.13.0

frontend/app/globals.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
@import "tailwindcss";
22

3+
@custom-variant dark (&:where(.dark, .dark *));
4+
35
:root {
46
--background: #ffffff;
57
--foreground: #171717;
@@ -14,10 +16,15 @@
1416
--color-monaco-red-dark: var(--monaco-red-dark);
1517
--font-sans: var(--font-geist-sans);
1618
--font-mono: var(--font-geist-mono);
19+
--font-lora: var(--font-lora);
1720
}
1821

1922
body {
2023
background: var(--background);
2124
color: var(--foreground);
2225
font-family: var(--font-geist-sans), system-ui, sans-serif;
2326
}
27+
28+
h1, h2, h3 {
29+
font-family: var(--font-lora), Georgia, serif;
30+
}

frontend/app/layout.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import type { Metadata } from "next";
22
import { Geist } from "next/font/google";
3+
import { Lora } from "next/font/google";
34
import "./globals.css";
45
import Nav from "@/components/nav";
6+
import { ThemeProvider } from "@/components/theme-provider";
7+
import SwRegister from "@/components/sw-register";
8+
import PwaInstall from "@/components/pwa-install";
59

610
const geist = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
11+
const lora = Lora({
12+
variable: "--font-lora",
13+
subsets: ["latin"],
14+
display: "swap",
15+
});
716

817
export const metadata: Metadata = {
918
title: "Monalex — Dictionnaire français-monégasque",
1019
description:
1120
"Explorez le riche patrimoine linguistique de Monaco. Dictionnaire français-monégasque avec 14 000 entrées, conjugaisons et assistant IA.",
21+
manifest: "/manifest.json",
22+
appleWebApp: {
23+
capable: true,
24+
statusBarStyle: "default",
25+
title: "Monalex",
26+
},
1227
};
1328

1429
export default function RootLayout({
@@ -17,13 +32,28 @@ export default function RootLayout({
1732
children: React.ReactNode;
1833
}) {
1934
return (
20-
<html lang="fr" className={`${geist.variable} h-full antialiased`}>
21-
<body className="min-h-full flex flex-col bg-white text-gray-900">
22-
<Nav />
23-
<main className="flex-1">{children}</main>
24-
<footer className="border-t border-gray-100 py-6 text-center text-sm text-gray-400">
25-
© {new Date().getFullYear()} Monalex — Dictionnaire français-monégasque
26-
</footer>
35+
<html lang="fr" className={`${geist.variable} ${lora.variable} h-full antialiased`}>
36+
<head>
37+
<meta name="theme-color" content="#ce1126" />
38+
<meta name="mobile-web-app-capable" content="yes" />
39+
<link rel="apple-touch-icon" href="/icons/icon.svg" />
40+
{/* Prevent flash of wrong theme before React hydrates */}
41+
<script
42+
dangerouslySetInnerHTML={{
43+
__html: `(function(){try{var s=localStorage.getItem('theme');var p=window.matchMedia('(prefers-color-scheme: dark)').matches;if(s==='dark'||(s===null&&p))document.documentElement.classList.add('dark');}catch(e){}})();`,
44+
}}
45+
/>
46+
</head>
47+
<body className="min-h-full flex flex-col bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100 transition-colors">
48+
<ThemeProvider>
49+
<Nav />
50+
<main className="flex-1">{children}</main>
51+
<footer className="border-t border-gray-100 dark:border-slate-800 py-6 text-center text-sm text-gray-400 dark:text-slate-500">
52+
© {new Date().getFullYear()} Monalex — Dictionnaire français-monégasque
53+
</footer>
54+
<PwaInstall />
55+
</ThemeProvider>
56+
<SwRegister />
2757
</body>
2858
</html>
2959
);

frontend/app/page.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,26 @@ async function WordOfDaySection() {
1414
<div className="mb-3 text-xs font-semibold uppercase tracking-widest text-[#ce1126]">
1515
Mot du jour — {wotd.date}
1616
</div>
17-
<div className="border border-gray-200 rounded-2xl p-6 shadow-sm">
18-
<p className="text-xl font-bold text-gray-900">{wotd.word}</p>
19-
<p className="text-gray-500 mt-1 mb-5">{wotd.definition}</p>
17+
<div className="border border-gray-200 dark:border-slate-700 rounded-2xl p-6 shadow-sm bg-white dark:bg-slate-800/50">
18+
<p className="text-xl font-bold text-gray-900 dark:text-slate-100">{wotd.word}</p>
19+
<p className="text-gray-500 dark:text-slate-400 mt-1 mb-5">{wotd.definition}</p>
2020

2121
<div className="space-y-4 text-sm">
22-
<p className="text-gray-700">{wotd.explanation.summary_fr}</p>
22+
<p className="text-gray-700 dark:text-slate-300">{wotd.explanation.summary_fr}</p>
2323

2424
{wotd.explanation.examples.length > 0 && (
2525
<div className="space-y-2">
2626
{wotd.explanation.examples.map((ex, i) => (
27-
<div key={i} className="bg-gray-50 rounded-xl px-4 py-3">
28-
<p className="text-gray-600">{ex.fr}</p>
27+
<div key={i} className="bg-gray-50 dark:bg-slate-800 rounded-xl px-4 py-3">
28+
<p className="text-gray-600 dark:text-slate-400">{ex.fr}</p>
2929
<p className="text-[#ce1126] font-medium mt-0.5">{ex.monegasque}</p>
3030
</div>
3131
))}
3232
</div>
3333
)}
3434

3535
{wotd.explanation.memory_tip && (
36-
<p className="text-amber-700 bg-amber-50 rounded-xl px-4 py-3">
36+
<p className="text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl px-4 py-3">
3737
{wotd.explanation.memory_tip}
3838
</p>
3939
)}
@@ -92,11 +92,11 @@ export default function HomePage() {
9292
<WordOfDaySection />
9393

9494
{/* About */}
95-
<section className="max-w-3xl mx-auto px-4 py-12 border-t border-gray-100">
96-
<h2 className="text-xl font-bold text-gray-900 mb-6">
95+
<section className="max-w-3xl mx-auto px-4 py-12 border-t border-gray-100 dark:border-slate-800">
96+
<h2 className="text-xl font-bold text-gray-900 dark:text-slate-100 mb-6">
9797
Histoire de la langue
9898
</h2>
99-
<div className="space-y-4 text-sm text-gray-600 leading-relaxed">
99+
<div className="space-y-4 text-sm text-gray-600 dark:text-slate-400 leading-relaxed">
100100
<p>
101101
Le monégasque (u munegascu) est un dialecte ligure, parlé en
102102
Principauté de Monaco. Les premières traces écrites apparaissent

0 commit comments

Comments
 (0)