Skip to content

Commit 64a33a9

Browse files
Merge pull request #4 from PSchmitz-Valckenberg/feat/phase-1-polish
feat: Phase 1 — PWA, dark mode, search history, direction toggle, SQLite filelock
2 parents 3bc600e + 8f69c66 commit 64a33a9

15 files changed

Lines changed: 492 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: 6 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;
@@ -21,3 +23,7 @@ body {
2123
color: var(--foreground);
2224
font-family: var(--font-geist-sans), system-ui, sans-serif;
2325
}
26+
27+
h1, h2, h3 {
28+
font-family: var(--font-lora), Georgia, serif;
29+
}

frontend/app/layout.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
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+
},
27+
// TODO: replace with a real 180×180 PNG (SVG is ignored by iOS for home screen icons)
28+
icons: { icon: "/icons/icon.svg" },
1229
};
1330

1431
export default function RootLayout({
@@ -17,13 +34,27 @@ export default function RootLayout({
1734
children: React.ReactNode;
1835
}) {
1936
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>
37+
<html lang="fr" className={`${geist.variable} ${lora.variable} h-full antialiased`}>
38+
<head>
39+
<meta name="theme-color" content="#ce1126" />
40+
<meta name="mobile-web-app-capable" content="yes" />
41+
{/* Prevent flash of wrong theme before React hydrates */}
42+
<script
43+
dangerouslySetInnerHTML={{
44+
__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){}})();`,
45+
}}
46+
/>
47+
</head>
48+
<body className="min-h-full flex flex-col bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100 transition-colors">
49+
<ThemeProvider>
50+
<Nav />
51+
<main className="flex-1">{children}</main>
52+
<footer className="border-t border-gray-100 dark:border-slate-800 py-6 text-center text-sm text-gray-400 dark:text-slate-500">
53+
© {new Date().getFullYear()} Monalex — Dictionnaire français-monégasque
54+
</footer>
55+
<PwaInstall />
56+
</ThemeProvider>
57+
<SwRegister />
2758
</body>
2859
</html>
2960
);

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)