Compare commits
8 Commits
4689cb5971
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b425e7f3b6 | |||
| a9abf582f5 | |||
| 4dc4d615e5 | |||
| aa70c61abd | |||
| 093b27f3f4 | |||
| db54882933 | |||
| 306c15de65 | |||
| a2938e0d64 |
@@ -70,7 +70,7 @@ Instances talk to each other over ActivityPub, and nobody stands in the middle.
|
|||||||
|
|
||||||
**Comfort and access**
|
**Comfort and access**
|
||||||
- Light, dark, and system themes, warm palettes, and text scaling up to 200%
|
- Light, dark, and system themes, warm palettes, and text scaling up to 200%
|
||||||
- A screen-reader native grid: every cell is focusable and announced with its number, letter, and state, and Alt+A / Alt+D read the across and down words aloud (/ reads the clue, space reads the cell, Ctrl+H the puzzle overview)
|
- A screen-reader native grid: every cell is focusable and announced with its number, letter, and state, and Alt+A / Alt+D read the across and down words aloud (/ or Alt+P reads the clue, space reads the cell, Ctrl+H the puzzle overview)
|
||||||
- A voice narrator with three verbosity levels and an output mode (voice, screen reader, or both) so blind players get the same flow through their own screen reader
|
- A voice narrator with three verbosity levels and an output mode (voice, screen reader, or both) so blind players get the same flow through their own screen reader
|
||||||
- Colorblind palettes and reduced-motion cell animations
|
- Colorblind palettes and reduced-motion cell animations
|
||||||
- Sound effects and a tor-friendly mode that keeps the page light for slow connections
|
- Sound effects and a tor-friendly mode that keeps the page light for slow connections
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ pub fn to_pdf(puzzle: &Puzzle, title: &str, variant: &str) -> Vec<u8> {
|
|||||||
|
|
||||||
c.push_str(&format!("BT /F2 6 Tf 0.6 0.55 0.5 rg {} {} Td (cruciverb) Tj ET\n", margin, margin - 10.0));
|
c.push_str(&format!("BT /F2 6 Tf 0.6 0.55 0.5 rg {} {} Td (cruciverb) Tj ET\n", margin, margin - 10.0));
|
||||||
|
|
||||||
build_pdf_with_fonts(&c, pw, ph, bold_font.as_deref(), regular_font.as_deref())
|
build_pdf_clean(&c, pw, ph, regular_font.as_deref(), bold_font.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn esc(s: &str) -> String {
|
fn esc(s: &str) -> String {
|
||||||
@@ -130,100 +130,16 @@ fn esc(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn truncate(s: &str, max: usize) -> String {
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
if s.len() <= max { s.to_string() } else { format!("{}...", &s[..max - 3]) }
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let keep = max.saturating_sub(3);
|
||||||
|
let cut: String = s.chars().take(keep).collect();
|
||||||
|
format!("{}...", cut)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_pdf_with_fonts(content: &str, pw: f64, ph: f64, bold_ttf: Option<&[u8]>, regular_ttf: Option<&[u8]>) -> Vec<u8> {
|
|
||||||
let mut pdf = Vec::new();
|
|
||||||
let mut offsets = Vec::new();
|
|
||||||
let mut obj_count = 0;
|
|
||||||
|
|
||||||
pdf.extend_from_slice(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
|
|
||||||
|
|
||||||
macro_rules! obj {
|
|
||||||
($data:expr) => {{
|
|
||||||
obj_count += 1;
|
|
||||||
offsets.push(pdf.len());
|
|
||||||
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
|
|
||||||
pdf.extend_from_slice($data);
|
|
||||||
pdf.extend_from_slice(b"\nendobj\n");
|
|
||||||
obj_count
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
// obj 1: catalog
|
|
||||||
obj!(b"<< /Type /Catalog /Pages 2 0 R >>");
|
|
||||||
|
|
||||||
// obj 2: pages
|
|
||||||
obj!(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
||||||
|
|
||||||
// build font objects
|
|
||||||
let (f1_ref, f2_ref);
|
|
||||||
|
|
||||||
if let Some(reg_data) = regular_ttf {
|
|
||||||
// embedded TrueType font for F1 (regular/body)
|
|
||||||
let stream_id = {
|
|
||||||
obj_count += 1;
|
|
||||||
offsets.push(pdf.len());
|
|
||||||
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
|
|
||||||
pdf.extend_from_slice(format!("<< /Length {} /Length1 {} >>\nstream\n", reg_data.len(), reg_data.len()).as_bytes());
|
|
||||||
pdf.extend_from_slice(reg_data);
|
|
||||||
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
|
||||||
obj_count
|
|
||||||
};
|
|
||||||
let desc_id = obj!(format!("<< /Type /FontDescriptor /FontName /DMSans /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 80 >>", stream_id).as_bytes());
|
|
||||||
f1_ref = obj!(format!("<< /Type /Font /Subtype /TrueType /BaseFont /DMSans /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id).as_bytes());
|
|
||||||
} else {
|
|
||||||
f1_ref = obj!(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bold_data) = bold_ttf {
|
|
||||||
let stream_id = {
|
|
||||||
obj_count += 1;
|
|
||||||
offsets.push(pdf.len());
|
|
||||||
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
|
|
||||||
pdf.extend_from_slice(format!("<< /Length {} /Length1 {} >>\nstream\n", bold_data.len(), bold_data.len()).as_bytes());
|
|
||||||
pdf.extend_from_slice(bold_data);
|
|
||||||
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
|
||||||
obj_count
|
|
||||||
};
|
|
||||||
let desc_id = obj!(format!("<< /Type /FontDescriptor /FontName /PlayfairDisplay-Bold /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 120 >>", stream_id).as_bytes());
|
|
||||||
f2_ref = obj!(format!("<< /Type /Font /Subtype /TrueType /BaseFont /PlayfairDisplay-Bold /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id).as_bytes());
|
|
||||||
} else {
|
|
||||||
f2_ref = obj!(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// obj: content stream
|
|
||||||
let content_id = {
|
|
||||||
obj_count += 1;
|
|
||||||
offsets.push(pdf.len());
|
|
||||||
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
|
|
||||||
pdf.extend_from_slice(format!("<< /Length {} >>\nstream\n", content.len()).as_bytes());
|
|
||||||
pdf.extend_from_slice(content.as_bytes());
|
|
||||||
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
|
||||||
obj_count
|
|
||||||
};
|
|
||||||
|
|
||||||
// obj 3: page (deferred - write now)
|
|
||||||
let page_data = format!(
|
|
||||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents {} 0 R /Resources << /Font << /F1 {} 0 R /F2 {} 0 R >> >> >>",
|
|
||||||
pw, ph, content_id, f1_ref, f2_ref
|
|
||||||
);
|
|
||||||
// we need to insert page as obj 3, but we already wrote objects 1,2 then font objects
|
|
||||||
// so obj 3 wasn't written yet. let's write it now
|
|
||||||
obj_count += 1;
|
|
||||||
offsets.push(pdf.len());
|
|
||||||
pdf.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", obj_count, page_data).as_bytes());
|
|
||||||
let _page_obj = obj_count;
|
|
||||||
|
|
||||||
// fix pages to reference our page object
|
|
||||||
// this is hacky but works - we'll just rebuild the whole thing properly
|
|
||||||
|
|
||||||
// actually let's just rebuild cleanly
|
|
||||||
drop(pdf);
|
|
||||||
drop(offsets);
|
|
||||||
build_pdf_clean(content, pw, ph, regular_ttf, bold_ttf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_pdf_clean(content: &str, pw: f64, ph: f64, regular_ttf: Option<&[u8]>, bold_ttf: Option<&[u8]>) -> Vec<u8> {
|
fn build_pdf_clean(content: &str, pw: f64, ph: f64, regular_ttf: Option<&[u8]>, bold_ttf: Option<&[u8]>) -> Vec<u8> {
|
||||||
// collect all objects first, then serialize
|
// collect all objects first, then serialize
|
||||||
|
|||||||
@@ -241,6 +241,16 @@ pub fn generate_themed_puzzle(
|
|||||||
) -> Result<Puzzle> {
|
) -> Result<Puzzle> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Bias the fill toward theme words when a theme is requested.
|
||||||
|
let theme_boost = theme_words
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.map(|tw| {
|
||||||
|
let mut d = dict.clone();
|
||||||
|
d.boost_theme(tw);
|
||||||
|
d
|
||||||
|
});
|
||||||
|
let solve_dict = theme_boost.as_ref().unwrap_or(dict);
|
||||||
|
|
||||||
let cores = std::thread::available_parallelism()
|
let cores = std::thread::available_parallelism()
|
||||||
.map(|n| n.get())
|
.map(|n| n.get())
|
||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
@@ -250,7 +260,7 @@ pub fn generate_themed_puzzle(
|
|||||||
if cores > 1 && config.width > 9 {
|
if cores > 1 && config.width > 9 {
|
||||||
return parallel_generate(
|
return parallel_generate(
|
||||||
config,
|
config,
|
||||||
dict,
|
solve_dict,
|
||||||
clue_db,
|
clue_db,
|
||||||
difficulty,
|
difficulty,
|
||||||
theme,
|
theme,
|
||||||
@@ -260,7 +270,7 @@ pub fn generate_themed_puzzle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sequential_generate(config, dict, clue_db, difficulty, theme, theme_words, start)
|
sequential_generate(config, solve_dict, clue_db, difficulty, theme, theme_words, start)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SolvedGrid {
|
struct SolvedGrid {
|
||||||
|
|||||||
@@ -1,49 +1,3 @@
|
|||||||
use crate::dict::Dictionary;
|
|
||||||
|
|
||||||
pub fn score_candidate(
|
|
||||||
word_idx: usize,
|
|
||||||
dict: &Dictionary,
|
|
||||||
used_words: &[bool],
|
|
||||||
placed_texts: &[String],
|
|
||||||
) -> f64 {
|
|
||||||
if used_words[word_idx] {
|
|
||||||
return -1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let word = dict.get(word_idx);
|
|
||||||
let text = &word.text;
|
|
||||||
let mut score = word.frequency;
|
|
||||||
|
|
||||||
// reject words that are too similar to already-placed words
|
|
||||||
for placed in placed_texts {
|
|
||||||
if is_related(text, placed) {
|
|
||||||
return -1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// bonus for letter variety
|
|
||||||
let bytes = text.as_bytes();
|
|
||||||
let mut seen = [false; 26];
|
|
||||||
let mut unique = 0;
|
|
||||||
for &b in bytes {
|
|
||||||
let idx = (b - b'A') as usize;
|
|
||||||
if !seen[idx] {
|
|
||||||
seen[idx] = true;
|
|
||||||
unique += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let variety = unique as f64 / bytes.len() as f64;
|
|
||||||
score += variety * 0.1;
|
|
||||||
|
|
||||||
// bonus for common letters
|
|
||||||
let common = b"ETAOINSHRDLU";
|
|
||||||
let common_count = bytes.iter().filter(|b| common.contains(b)).count();
|
|
||||||
let common_ratio = common_count as f64 / bytes.len() as f64;
|
|
||||||
score += common_ratio * 0.05;
|
|
||||||
|
|
||||||
score
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_related(a: &str, b: &str) -> bool {
|
pub fn is_related(a: &str, b: &str) -> bool {
|
||||||
// one is a prefix of the other (SEAL / SEALS, RUN / RUNNING)
|
// one is a prefix of the other (SEAL / SEALS, RUN / RUNNING)
|
||||||
if a.len() >= 3 && b.len() >= 3 {
|
if a.len() >= 3 && b.len() >= 3 {
|
||||||
|
|||||||
@@ -890,7 +890,7 @@ async fn handle_leaderboard(state: &Arc<AppState>, activity: &serde_json::Value,
|
|||||||
|
|
||||||
// check instance reputation
|
// check instance reputation
|
||||||
let reputation: f64 = dbi.db.query_row(
|
let reputation: f64 = dbi.db.query_row(
|
||||||
"SELECT reputation FROM ap_instance_reputation WHERE domain = ?1",
|
"SELECT score FROM ap_instance_reputation WHERE domain = ?1",
|
||||||
[origin], |row| row.get(0),
|
[origin], |row| row.get(0),
|
||||||
).unwrap_or(100.0);
|
).unwrap_or(100.0);
|
||||||
if reputation < 30.0 {
|
if reputation < 30.0 {
|
||||||
|
|||||||
@@ -252,10 +252,12 @@ pub async fn admin_setup(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<serde_json::Value>,
|
Json(req): Json<serde_json::Value>,
|
||||||
) -> Result<Json<serde_json::Value>, Response> {
|
) -> Result<Json<serde_json::Value>, Response> {
|
||||||
let dbi = state.db.lock().await;
|
// Optional first-run gate: if ADMIN_SETUP_KEY is set, a matching key must
|
||||||
|
// be supplied so a random visitor cannot claim the instance first.
|
||||||
if db::has_admin_account(&dbi.db) {
|
if let Ok(key) = std::env::var("ADMIN_SETUP_KEY") {
|
||||||
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
|
if req.get("key").and_then(|v| v.as_str()) != Some(key.as_str()) {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "invalid setup key").into_response());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
@@ -281,7 +283,20 @@ pub async fn admin_setup(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
db::create_admin_account(&dbi.db, username, &stored);
|
// Atomic check-and-create inside a transaction so two concurrent setup
|
||||||
|
// requests cannot both pass the "no admin yet" check and create two admins.
|
||||||
|
let dbi = state.db.lock().await;
|
||||||
|
let tx = match dbi.db.unchecked_transaction() {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()),
|
||||||
|
};
|
||||||
|
if db::has_admin_account(&tx) {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
|
||||||
|
}
|
||||||
|
db::create_admin_account(&tx, username, &stored);
|
||||||
|
if tx.commit().is_err() {
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response());
|
||||||
|
}
|
||||||
db::log_admin_action(&dbi.db, "admin_setup", Some(username), None);
|
db::log_admin_action(&dbi.db, "admin_setup", Some(username), None);
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
@@ -388,7 +403,7 @@ pub async fn stats(
|
|||||||
if let Ok(mut stmt) = conn.prepare(
|
if let Ok(mut stmt) = conn.prepare(
|
||||||
"SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END)
|
"SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END)
|
||||||
FROM community_clues cc
|
FROM community_clues cc
|
||||||
LEFT JOIN sessions s ON cc.submitted_by = s.token
|
LEFT JOIN sessions s ON cc.submitted_by = s.account_id
|
||||||
LEFT JOIN settings st ON cc.submitted_by = st.session_token
|
LEFT JOIN settings st ON cc.submitted_by = st.session_token
|
||||||
GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10"
|
GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10"
|
||||||
) {
|
) {
|
||||||
@@ -468,9 +483,9 @@ pub async fn list_sessions(
|
|||||||
check_admin(&headers).await?;
|
check_admin(&headers).await?;
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let mut stmt = dbi.db.prepare(
|
let mut stmt = dbi.db.prepare(
|
||||||
"SELECT s.token, s.display_name, s.created_at, s.last_seen,
|
"SELECT s.account_id, s.display_name, s.created_at, s.last_seen,
|
||||||
(SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.token AND sr.is_complete = 1),
|
(SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.account_id AND sr.is_complete = 1),
|
||||||
(SELECT current_streak FROM streaks st WHERE st.session_token = s.token),
|
(SELECT current_streak FROM streaks st WHERE st.session_token = s.account_id),
|
||||||
COALESCE(s.banned, 0)
|
COALESCE(s.banned, 0)
|
||||||
FROM sessions s ORDER BY s.last_seen DESC LIMIT 100"
|
FROM sessions s ORDER BY s.last_seen DESC LIMIT 100"
|
||||||
).unwrap();
|
).unwrap();
|
||||||
@@ -495,7 +510,7 @@ pub async fn delete_session(
|
|||||||
) -> Result<Json<serde_json::Value>, Response> {
|
) -> Result<Json<serde_json::Value>, Response> {
|
||||||
check_admin(&headers).await?;
|
check_admin(&headers).await?;
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
dbi.db.execute("DELETE FROM sessions WHERE token = ?1", [&token]).ok();
|
dbi.db.execute("DELETE FROM sessions WHERE account_id = ?1", [&token]).ok();
|
||||||
dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok();
|
dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok();
|
||||||
db::log_admin_action(&dbi.db, "delete_session", Some(&token), None);
|
db::log_admin_action(&dbi.db, "delete_session", Some(&token), None);
|
||||||
invalidate_stats_cache();
|
invalidate_stats_cache();
|
||||||
@@ -1395,7 +1410,7 @@ pub async fn list_community_puzzles(
|
|||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let mut stmt = dbi.db.prepare(
|
let mut stmt = dbi.db.prepare(
|
||||||
"SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at
|
"SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at
|
||||||
FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.token
|
FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.account_id
|
||||||
WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200"
|
WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200"
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
|
let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
|
||||||
|
|||||||
@@ -751,7 +751,7 @@ pub async fn v1_create_pack(
|
|||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let (_, author_name) = db::get_or_create_session(&dbi.db, &auth.session_token);
|
let author_name = db::account_display_name(&dbi.db, &auth.session_token);
|
||||||
let ok = db::create_pack(&dbi.db, &id,
|
let ok = db::create_pack(&dbi.db, &id,
|
||||||
&crate::security::sanitize_html(name),
|
&crate::security::sanitize_html(name),
|
||||||
desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
|
desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
|
||||||
@@ -774,7 +774,7 @@ pub async fn v1_me(
|
|||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token);
|
let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token);
|
||||||
let has_passkey = db::has_passkey(&dbi.db, &auth.session_token);
|
let has_passkey = db::has_passkey(&dbi.db, &auth.session_token);
|
||||||
let (_, name) = db::get_or_create_session(&dbi.db, &auth.session_token);
|
let name = db::account_display_name(&dbi.db, &auth.session_token);
|
||||||
|
|
||||||
api_ok(serde_json::json!({
|
api_ok(serde_json::json!({
|
||||||
"display_name": name,
|
"display_name": name,
|
||||||
|
|||||||
+161
-80
@@ -490,22 +490,73 @@ pub fn init_db(path: &Path) -> Connection {
|
|||||||
db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", [])
|
db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", [])
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
|
// additive identity-refactor scaffolding (safe, behavior-unchanged):
|
||||||
|
// stable accounts alongside session credentials, so existing installs
|
||||||
|
// upgrade automatically on startup.
|
||||||
|
db.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let has_account_col: bool = db
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'account_id'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
> 0;
|
||||||
|
if !has_account_col {
|
||||||
|
db.execute("ALTER TABLE sessions ADD COLUMN account_id TEXT", []).ok();
|
||||||
|
}
|
||||||
|
db.execute_batch(
|
||||||
|
"UPDATE sessions SET account_id = token WHERE account_id IS NULL;",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.execute_batch(
|
||||||
|
"INSERT OR IGNORE INTO accounts (id) SELECT DISTINCT account_id FROM sessions WHERE account_id IS NOT NULL;",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// one-time: hash legacy plaintext session tokens at rest. A session whose
|
||||||
|
// token still equals its account_id has not been hashed yet (after hashing
|
||||||
|
// they diverge), so this is idempotent across restarts and installs.
|
||||||
|
let legacy: Vec<String> = {
|
||||||
|
let mut stmt = db
|
||||||
|
.prepare("SELECT token FROM sessions WHERE token = account_id")
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
|
||||||
|
rows.filter_map(Result::ok).collect()
|
||||||
|
};
|
||||||
|
if !legacy.is_empty() {
|
||||||
|
if let Ok(tx) = db.unchecked_transaction() {
|
||||||
|
for t in legacy {
|
||||||
|
let _ = tx.execute(
|
||||||
|
"UPDATE sessions SET token = ?1 WHERE token = ?2",
|
||||||
|
params![token_hash(&t), &t],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = tx.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ban_session(db: &Connection, token: &str) {
|
pub fn ban_session(db: &Connection, token: &str) {
|
||||||
db.execute("UPDATE sessions SET banned = 1 WHERE token = ?1", [token])
|
db.execute("UPDATE sessions SET banned = 1 WHERE account_id = ?1", [token])
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unban_session(db: &Connection, token: &str) {
|
pub fn unban_session(db: &Connection, token: &str) {
|
||||||
db.execute("UPDATE sessions SET banned = 0 WHERE token = ?1", [token])
|
db.execute("UPDATE sessions SET banned = 0 WHERE account_id = ?1", [token])
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_banned(db: &Connection, token: &str) -> bool {
|
pub fn is_banned(db: &Connection, token: &str) -> bool {
|
||||||
db.query_row(
|
db.query_row(
|
||||||
"SELECT banned FROM sessions WHERE token = ?1",
|
"SELECT COUNT(*) FROM sessions WHERE account_id = ?1 AND banned = 1",
|
||||||
[token],
|
[token],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
@@ -557,7 +608,7 @@ pub fn cleanup_user_content(db: &Connection, token: &str) {
|
|||||||
.ok();
|
.ok();
|
||||||
// clear display name so deleted/banned users can't be looked up
|
// clear display name so deleted/banned users can't be looked up
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE sessions SET display_name = NULL WHERE token = ?1",
|
"UPDATE sessions SET display_name = NULL WHERE account_id = ?1",
|
||||||
[token],
|
[token],
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
@@ -845,30 +896,79 @@ pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sessions
|
// sessions
|
||||||
|
pub fn token_hash(token: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
hex::encode(Sha256::digest(token.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_account_id() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||||
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a bearer token to its account id, creating a fresh account+session
|
||||||
|
// when unknown. Returns (account id, display name).
|
||||||
pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option<String>) {
|
pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option<String>) {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let key = token_hash(token);
|
||||||
|
|
||||||
let existing = db.query_row(
|
let existing = db.query_row(
|
||||||
"SELECT token, display_name FROM sessions WHERE token = ?1",
|
"SELECT account_id, display_name FROM sessions WHERE token = ?1",
|
||||||
[token],
|
[&key],
|
||||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
|row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Ok((t, name)) = existing {
|
if let Ok((account_id, name)) = existing {
|
||||||
|
let account_id = account_id.unwrap_or_else(|| key.clone());
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE sessions SET last_seen = ?1 WHERE token = ?2",
|
"UPDATE sessions SET last_seen = ?1 WHERE token = ?2",
|
||||||
params![now, token],
|
params![now, &key],
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
return (t, name);
|
return (account_id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let account_id = new_account_id();
|
||||||
|
db.execute("INSERT OR IGNORE INTO accounts (id) VALUES (?1)", [&account_id]).ok();
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)",
|
"INSERT INTO sessions (token, account_id, created_at, last_seen) VALUES (?1, ?2, ?3, ?3)",
|
||||||
params![token, now],
|
params![&key, &account_id, now],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(token.to_string(), None)
|
(account_id, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a fresh session (new bearer) attached to an existing account, used by
|
||||||
|
// passkey login to resume an account on a new device.
|
||||||
|
pub fn create_session_for_account(db: &Connection, token: &str, account_id: &str) -> (String, Option<String>) {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let key = token_hash(token);
|
||||||
|
let name: Option<String> = db
|
||||||
|
.query_row(
|
||||||
|
"SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1",
|
||||||
|
[account_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO sessions (token, account_id, display_name, created_at, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)",
|
||||||
|
params![&key, account_id, name, now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(account_id.to_string(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_display_name(db: &Connection, account_id: &str) -> Option<String> {
|
||||||
|
db.query_row(
|
||||||
|
"SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1",
|
||||||
|
[account_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option<String> {
|
pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option<String> {
|
||||||
@@ -891,7 +991,7 @@ pub fn set_user_agent_hash(db: &Connection, token: &str, hash: &str) {
|
|||||||
|
|
||||||
pub fn set_display_name(db: &Connection, token: &str, name: &str) {
|
pub fn set_display_name(db: &Connection, token: &str, name: &str) {
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE sessions SET display_name = ?1 WHERE token = ?2",
|
"UPDATE sessions SET display_name = ?1 WHERE account_id = ?2",
|
||||||
params![name, token],
|
params![name, token],
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
@@ -1123,8 +1223,13 @@ pub fn get_community_clues(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool {
|
pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool {
|
||||||
|
let tx = match db.unchecked_transaction() {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
// check if already voted
|
// check if already voted
|
||||||
let existing: Option<i32> = db
|
let existing: Option<i32> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2",
|
"SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2",
|
||||||
params![token, clue_id],
|
params![token, clue_id],
|
||||||
@@ -1134,65 +1239,57 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
|
|||||||
|
|
||||||
if let Some(old_vote) = existing {
|
if let Some(old_vote) = existing {
|
||||||
if old_vote == vote {
|
if old_vote == vote {
|
||||||
return false;
|
return false; // same vote, no change; tx rolls back on drop
|
||||||
} // same vote, no change
|
}
|
||||||
// undo old vote
|
// undo old vote
|
||||||
if old_vote > 0 {
|
if old_vote > 0 {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1",
|
"UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
} else {
|
} else {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1",
|
"UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
// apply new vote
|
// apply new vote
|
||||||
if vote > 0 {
|
if vote > 0 {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
|
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
} else {
|
} else {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
|
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3",
|
"UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3",
|
||||||
params![vote, token, clue_id],
|
params![vote, token, clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
} else {
|
} else {
|
||||||
// new vote
|
// new vote
|
||||||
if vote > 0 {
|
if vote > 0 {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
|
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
} else {
|
} else {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
|
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)",
|
"INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)",
|
||||||
params![token, clue_id, vote],
|
params![token, clue_id, vote],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto-approve clues with net +3 votes, auto-reject at net -3
|
// auto-approve clues with net +3 votes, auto-reject at net -3
|
||||||
let (up, down): (i64, i64) = db
|
let (up, down): (i64, i64) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT upvotes, downvotes FROM community_clues WHERE id = ?1",
|
"SELECT upvotes, downvotes FROM community_clues WHERE id = ?1",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
@@ -1201,20 +1298,18 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
|
|||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
if up - down >= 3 {
|
if up - down >= 3 {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
|
"UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
} else if down - up >= 3 {
|
} else if down - up >= 3 {
|
||||||
db.execute(
|
let _ = tx.execute(
|
||||||
"UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
|
"UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
|
||||||
[clue_id],
|
[clue_id],
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
tx.commit().is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> {
|
pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> {
|
||||||
@@ -1538,7 +1633,7 @@ pub fn list_published_puzzles(
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain
|
"SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain
|
||||||
FROM user_puzzles up
|
FROM user_puzzles up
|
||||||
LEFT JOIN sessions s ON up.session_token = s.token
|
LEFT JOIN sessions s ON up.session_token = s.account_id
|
||||||
LEFT JOIN settings st ON up.session_token = st.session_token
|
LEFT JOIN settings st ON up.session_token = st.session_token
|
||||||
WHERE up.status = 'published' AND up.clues_json IS NOT NULL
|
WHERE up.status = 'published' AND up.clues_json IS NOT NULL
|
||||||
ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2"
|
ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2"
|
||||||
@@ -2025,23 +2120,9 @@ pub fn import_user_data(
|
|||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// streak (only if better than current)
|
// Ignore imported streak values: a client-supplied streak is untrusted and
|
||||||
if let Some(streak) = data.get("streak") {
|
// would let anyone inflate the streak leaderboard. Streaks are re-derived
|
||||||
let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0);
|
// from actual solves by update_streak.
|
||||||
let imp_current = streak.get("current").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
||||||
let imp_last = streak
|
|
||||||
.get("last_date")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let (cur_current, cur_best, _) = get_streak(db, token);
|
|
||||||
if imp_best > cur_best as i64 || imp_current > cur_current as i64 {
|
|
||||||
db.execute(
|
|
||||||
"INSERT OR REPLACE INTO streaks (session_token, current_streak, best_streak, last_solve_date) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![token, std::cmp::max(imp_current, cur_current as i64), std::cmp::max(imp_best, cur_best as i64), imp_last],
|
|
||||||
).ok();
|
|
||||||
imported += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// achievements (merge - only add ones not already unlocked)
|
// achievements (merge - only add ones not already unlocked)
|
||||||
if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) {
|
if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) {
|
||||||
@@ -2199,7 +2280,7 @@ pub fn delete_account(db: &Connection, token: &str) {
|
|||||||
[token],
|
[token],
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
db.execute("DELETE FROM sessions WHERE token = ?1", [token])
|
db.execute("DELETE FROM sessions WHERE account_id = ?1", [token])
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2207,7 +2288,7 @@ pub fn delete_account(db: &Connection, token: &str) {
|
|||||||
pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value {
|
pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value {
|
||||||
let session: serde_json::Value = db
|
let session: serde_json::Value = db
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT display_name, created_at, last_seen FROM sessions WHERE token = ?1",
|
"SELECT display_name, created_at, last_seen FROM sessions WHERE account_id = ?1",
|
||||||
[token],
|
[token],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@@ -2295,7 +2376,7 @@ pub fn get_leaderboard_filtered(
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
|
WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
|
||||||
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
|
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
|
||||||
@@ -2325,7 +2406,7 @@ pub fn get_daily_leaderboard_filtered(
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
|
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
|
||||||
@@ -2405,8 +2486,8 @@ pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 {
|
|||||||
let sessions: Vec<(String, String, String)> = {
|
let sessions: Vec<(String, String, String)> = {
|
||||||
let mut stmt = db
|
let mut stmt = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT s.token, st.settings_json, s.last_seen FROM sessions s
|
"SELECT s.account_id, st.settings_json, s.last_seen FROM sessions s
|
||||||
JOIN settings st ON st.session_token = s.token",
|
JOIN settings st ON st.session_token = s.account_id",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
@@ -2738,7 +2819,7 @@ pub fn get_alltime_leaderboard(
|
|||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used,
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used,
|
||||||
p.daily_date
|
p.daily_date
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
|
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
|
||||||
@@ -2771,7 +2852,7 @@ pub fn get_weekly_leaderboard(
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
|
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
|
||||||
@@ -2800,7 +2881,7 @@ pub fn get_streak_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak
|
||||||
FROM streaks st2
|
FROM streaks st2
|
||||||
LEFT JOIN sessions s ON st2.session_token = s.token
|
LEFT JOIN sessions s ON st2.session_token = s.account_id
|
||||||
LEFT JOIN settings st ON st2.session_token = st.session_token
|
LEFT JOIN settings st ON st2.session_token = st.session_token
|
||||||
WHERE st2.current_streak > 0
|
WHERE st2.current_streak > 0
|
||||||
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
|
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
|
||||||
@@ -2826,7 +2907,7 @@ pub fn get_completionist_leaderboard(db: &Connection, limit: usize) -> Vec<(Stri
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE sr.is_complete = 1 AND sr.flagged = 0
|
WHERE sr.is_complete = 1 AND sr.flagged = 0
|
||||||
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
|
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
|
||||||
@@ -2857,7 +2938,7 @@ pub fn get_clean_leaderboard(
|
|||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE p.daily_date = ?1 AND p.preset = ?2
|
WHERE p.daily_date = ?1 AND p.preset = ?2
|
||||||
@@ -3505,7 +3586,7 @@ pub fn get_daily_leaderboard_by_date(
|
|||||||
let sql = if clean_only {
|
let sql = if clean_only {
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
|
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
|
||||||
@@ -3516,7 +3597,7 @@ pub fn get_daily_leaderboard_by_date(
|
|||||||
} else {
|
} else {
|
||||||
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
|
||||||
FROM solve_records sr
|
FROM solve_records sr
|
||||||
LEFT JOIN sessions s ON sr.session_token = s.token
|
LEFT JOIN sessions s ON sr.session_token = s.account_id
|
||||||
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
|
||||||
LEFT JOIN settings st ON sr.session_token = st.session_token
|
LEFT JOIN settings st ON sr.session_token = st.session_token
|
||||||
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
|
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
|
||||||
|
|||||||
@@ -540,8 +540,8 @@ async fn main() {
|
|||||||
.allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE])
|
.allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE])
|
||||||
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")])
|
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")])
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("CORS_ORIGIN not set - using permissive CORS (set CORS_ORIGIN for production)");
|
tracing::warn!("CORS_ORIGIN not set - allowing same-origin requests only (set CORS_ORIGIN to allow cross-origin)");
|
||||||
CorsLayer::permissive()
|
CorsLayer::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = app
|
let app = app
|
||||||
|
|||||||
@@ -961,7 +961,7 @@ pub async fn create_room(
|
|||||||
RoomMode::Party => 20,
|
RoomMode::Party => 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tx, _) = broadcast::channel(256);
|
let (tx, _) = broadcast::channel(1024);
|
||||||
let grid = vec![vec![String::new(); width]; height];
|
let grid = vec![vec![String::new(); width]; height];
|
||||||
|
|
||||||
let party_sub_str = req.get("party_sub").and_then(|v| v.as_str()).unwrap_or("wrong");
|
let party_sub_str = req.get("party_sub").and_then(|v| v.as_str()).unwrap_or("wrong");
|
||||||
@@ -1374,6 +1374,31 @@ async fn handle_ws(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
// A slow consumer fell behind: re-send full state so the
|
||||||
|
// client resyncs instead of being silently dropped or
|
||||||
|
// disconnected.
|
||||||
|
let rooms = state.rooms.read().await;
|
||||||
|
if let Some(room) = rooms.get(&code) {
|
||||||
|
let resync = serde_json::to_string(&WsMessage::FullState {
|
||||||
|
grid: room.grid.clone(),
|
||||||
|
players: room.players.values().map(|p| PlayerInfo {
|
||||||
|
id: p.id.clone(),
|
||||||
|
name: p.name.clone(),
|
||||||
|
color: p.color.clone(),
|
||||||
|
cursor: p.cursor,
|
||||||
|
direction: p.direction.clone(),
|
||||||
|
order: p.order,
|
||||||
|
}).collect(),
|
||||||
|
current_turn: room.current_player_id().map(|s| s.to_string()),
|
||||||
|
started: room.started,
|
||||||
|
your_id: token.clone(),
|
||||||
|
}).unwrap();
|
||||||
|
if send_text(&mut socket, resync).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2701,17 +2701,22 @@ pub async fn passkey_auth_finish(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a session with the original token
|
// mint a fresh session attached to the account this passkey belongs to
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let (token, name) = db::get_or_create_session(&dbi.db, &session_token);
|
let new_plain = crate::session::generate_token();
|
||||||
let settings_json = db::get_settings(&dbi.db, &token);
|
let (account_id, name) = db::create_session_for_account(&dbi.db, &new_plain, &session_token);
|
||||||
|
let settings_json = db::get_settings(&dbi.db, &account_id);
|
||||||
let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json)
|
let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.get("cookieLifetime")?.as_u64())
|
.and_then(|v| v.get("cookieLifetime")?.as_u64())
|
||||||
.unwrap_or(2_592_000);
|
.unwrap_or(2_592_000);
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
token, display_name: name, is_new: false, cookie_max_age,
|
token: account_id,
|
||||||
|
cookie_token: new_plain,
|
||||||
|
display_name: name,
|
||||||
|
is_new: true,
|
||||||
|
cookie_max_age,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(with_session_cookie(&session, Json(serde_json::json!({
|
Ok(with_session_cookie(&session, Json(serde_json::json!({
|
||||||
@@ -2740,7 +2745,7 @@ pub async fn get_session_info(
|
|||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let info = dbi.db.query_row(
|
let info = dbi.db.query_row(
|
||||||
"SELECT created_at, last_seen FROM sessions WHERE token = ?1",
|
"SELECT created_at, last_seen FROM sessions WHERE account_id = ?1",
|
||||||
[&session.token],
|
[&session.token],
|
||||||
|row| Ok(serde_json::json!({
|
|row| Ok(serde_json::json!({
|
||||||
"created_at": row.get::<_, String>(0)?,
|
"created_at": row.get::<_, String>(0)?,
|
||||||
@@ -3446,7 +3451,7 @@ pub async fn create_pack(
|
|||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let (_, author_name) = db::get_or_create_session(&dbi.db, &session.token);
|
let author_name = db::account_display_name(&dbi.db, &session.token);
|
||||||
let success = db::create_pack(
|
let success = db::create_pack(
|
||||||
&dbi.db, &id,
|
&dbi.db, &id,
|
||||||
&crate::security::sanitize_html(name),
|
&crate::security::sanitize_html(name),
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ pub fn is_internal_url(url: &str) -> bool {
|
|||||||
if host.contains('@') { return true; }
|
if host.contains('@') { return true; }
|
||||||
|
|
||||||
// loopback
|
// loopback
|
||||||
host == "localhost"
|
let blocked = host == "localhost"
|
||||||
|| host.starts_with("127.") // full 127.0.0.0/8 range
|
|| host.starts_with("127.") // full 127.0.0.0/8 range
|
||||||
|| host == "::1"
|
|| host == "::1"
|
||||||
|| host.starts_with("::ffff:127.") // IPv4-mapped IPv6 loopback
|
|| host.starts_with("::ffff:127.") // IPv4-mapped IPv6 loopback
|
||||||
@@ -146,7 +146,8 @@ pub fn is_internal_url(url: &str) -> bool {
|
|||||||
// cloud metadata endpoints
|
// cloud metadata endpoints
|
||||||
|| host == "169.254.169.254"
|
|| host == "169.254.169.254"
|
||||||
|| host == "metadata.google.internal"
|
|| host == "metadata.google.internal"
|
||||||
|| host == "100.100.100.200" // Alibaba cloud metadata
|
|| host == "100.100.100.200"; // Alibaba cloud metadata
|
||||||
|
blocked || resolves_to_internal_ip(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_172_private(host: &str) -> bool {
|
fn is_172_private(host: &str) -> bool {
|
||||||
@@ -163,6 +164,47 @@ fn is_cgnat(host: &str) -> bool {
|
|||||||
(64..=127).contains(&second)
|
(64..=127).contains(&second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
let o = v4.octets();
|
||||||
|
o[0] == 0
|
||||||
|
|| o[0] == 10
|
||||||
|
|| o[0] == 127
|
||||||
|
|| (o[0] == 169 && o[1] == 254)
|
||||||
|
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|
||||||
|
|| (o[0] == 192 && o[1] == 168)
|
||||||
|
|| (o[0] == 100 && (64..=127).contains(&o[1]))
|
||||||
|
|| (o[0] == 198 && (o[1] == 18 || o[1] == 19))
|
||||||
|
|| o[0] >= 224
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
v6.is_loopback()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| v6.is_multicast()
|
||||||
|
|| (v6.segments()[0] & 0xfe00 == 0xfc00)
|
||||||
|
|| (v6.segments()[0] & 0xffc0 == 0xfe80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolves_to_internal_ip(host: &str) -> bool {
|
||||||
|
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
|
||||||
|
if host.is_empty() || host.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
let Ok(addrs) = (host, 0_u16).to_socket_addrs() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for addr in addrs {
|
||||||
|
if is_internal_ip(&addr.ip()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sanitize_html(input: &str) -> String {
|
pub fn sanitize_html(input: &str) -> String {
|
||||||
input
|
input
|
||||||
.replace('&', "&")
|
.replace('&', "&")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub cookie_token: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub is_new: bool,
|
pub is_new: bool,
|
||||||
pub cookie_max_age: u64,
|
pub cookie_max_age: u64,
|
||||||
@@ -16,16 +17,16 @@ pub struct Session {
|
|||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn cookie_header(&self) -> String {
|
pub fn cookie_header(&self) -> String {
|
||||||
let secure = std::env::var("SECURE_COOKIES").as_deref() == Ok("true");
|
let secure = std::env::var("SECURE_COOKIES").as_deref() != Ok("false");
|
||||||
let same_site = if secure { "Strict" } else { "Lax" };
|
let same_site = if secure { "Strict" } else { "Lax" };
|
||||||
let secure_flag = if secure { "; Secure" } else { "" };
|
let secure_flag = if secure { "; Secure" } else { "" };
|
||||||
|
|
||||||
if self.cookie_max_age == 0 {
|
if self.cookie_max_age == 0 {
|
||||||
format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.token, same_site, secure_flag)
|
format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.cookie_token, same_site, secure_flag)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}",
|
"cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}",
|
||||||
self.token, same_site, self.cookie_max_age, secure_flag
|
self.cookie_token, same_site, self.cookie_max_age, secure_flag
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ impl FromRequestParts<Arc<AppState>> for Session {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let token = parts
|
let cookie_token = parts
|
||||||
.headers
|
.headers
|
||||||
.get(header::COOKIE)
|
.get(header::COOKIE)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
@@ -57,15 +58,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let dbi = state.db.lock().await;
|
let dbi = state.db.lock().await;
|
||||||
let (mut token, mut name, mut is_new) = match token {
|
let (mut cookie_token, mut token, mut name, mut is_new) = match cookie_token {
|
||||||
Some(t) if !t.is_empty() => {
|
Some(t) if is_valid_token(&t) => {
|
||||||
let (token, name) = crate::db::get_or_create_session(&dbi.db, &t);
|
let (acct, name) = crate::db::get_or_create_session(&dbi.db, &t);
|
||||||
(token, name, false)
|
(t, acct, name, false)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let new_token = generate_token();
|
let new_token = generate_token();
|
||||||
let (token, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
|
let (acct, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
|
||||||
(token, name, true)
|
(new_token, acct, name, true)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,14 +83,20 @@ impl FromRequestParts<Arc<AppState>> for Session {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
&& crate::db::has_passkey(&dbi.db, &token);
|
&& crate::db::has_passkey(&dbi.db, &token);
|
||||||
if binding_enabled && !user_agent_hash.is_empty() {
|
if binding_enabled && !user_agent_hash.is_empty() {
|
||||||
let stored = crate::db::get_user_agent_hash(&dbi.db, &token);
|
let key = crate::db::token_hash(&cookie_token);
|
||||||
|
let stored = crate::db::get_user_agent_hash(&dbi.db, &key);
|
||||||
match stored {
|
match stored {
|
||||||
None => crate::db::set_user_agent_hash(&dbi.db, &token, &user_agent_hash),
|
None => crate::db::set_user_agent_hash(&dbi.db, &key, &user_agent_hash),
|
||||||
Some(s) if s != user_agent_hash => {
|
Some(s) if s != user_agent_hash => {
|
||||||
let new_token = generate_token();
|
let new_plain = generate_token();
|
||||||
let (t2, n2) = crate::db::get_or_create_session(&dbi.db, &new_token);
|
let (a2, n2) = crate::db::get_or_create_session(&dbi.db, &new_plain);
|
||||||
crate::db::set_user_agent_hash(&dbi.db, &t2, &user_agent_hash);
|
crate::db::set_user_agent_hash(
|
||||||
token = t2;
|
&dbi.db,
|
||||||
|
&crate::db::token_hash(&new_plain),
|
||||||
|
&user_agent_hash,
|
||||||
|
);
|
||||||
|
token = a2;
|
||||||
|
cookie_token = new_plain;
|
||||||
name = n2;
|
name = n2;
|
||||||
is_new = true;
|
is_new = true;
|
||||||
settings_json = crate::db::get_settings(&dbi.db, &token);
|
settings_json = crate::db::get_settings(&dbi.db, &token);
|
||||||
@@ -105,11 +112,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
|
|||||||
.and_then(|v| v.get("cookieLifetime")?.as_u64())
|
.and_then(|v| v.get("cookieLifetime")?.as_u64())
|
||||||
.unwrap_or(2_592_000);
|
.unwrap_or(2_592_000);
|
||||||
|
|
||||||
Ok(Session { token, display_name: name, is_new, cookie_max_age })
|
Ok(Session { token, cookie_token, display_name: name, is_new, cookie_max_age })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_token() -> String {
|
fn is_valid_token(t: &str) -> bool {
|
||||||
|
t.len() == 64 && t.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_token() -> String {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ pub fn create_registration_challenge(
|
|||||||
authenticator_attachment: None,
|
authenticator_attachment: None,
|
||||||
resident_key: "preferred".into(),
|
resident_key: "preferred".into(),
|
||||||
require_resident_key: false,
|
require_resident_key: false,
|
||||||
user_verification: "preferred".into(),
|
user_verification: "required".into(),
|
||||||
},
|
},
|
||||||
attestation: "none".into(),
|
attestation: "none".into(),
|
||||||
}
|
}
|
||||||
@@ -204,6 +204,10 @@ pub fn verify_registration(
|
|||||||
if flags & 0x01 == 0 {
|
if flags & 0x01 == 0 {
|
||||||
return Err("user not present".into());
|
return Err("user not present".into());
|
||||||
}
|
}
|
||||||
|
// UV flag (bit 2): user verification (PIN/biometric) is required
|
||||||
|
if flags & 0x04 == 0 {
|
||||||
|
return Err("user verification required".into());
|
||||||
|
}
|
||||||
if flags & 0x40 == 0 {
|
if flags & 0x40 == 0 {
|
||||||
return Err("no attested credential data".into());
|
return Err("no attested credential data".into());
|
||||||
}
|
}
|
||||||
@@ -301,6 +305,9 @@ pub fn verify_authentication(
|
|||||||
if auth_data[32] & 0x01 == 0 {
|
if auth_data[32] & 0x01 == 0 {
|
||||||
return Err("user not present".into());
|
return Err("user not present".into());
|
||||||
}
|
}
|
||||||
|
if auth_data[32] & 0x04 == 0 {
|
||||||
|
return Err("user verification required".into());
|
||||||
|
}
|
||||||
|
|
||||||
let counter = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
|
let counter = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence, MotionConfig } from 'motion/react'
|
|||||||
import { IconShare2 } from '@tabler/icons-react'
|
import { IconShare2 } from '@tabler/icons-react'
|
||||||
import Grid from './components/Grid'
|
import Grid from './components/Grid'
|
||||||
import CluePanel from './components/CluePanel'
|
import CluePanel from './components/CluePanel'
|
||||||
|
import ClueNav from './components/ClueNav'
|
||||||
import ActiveClue from './components/ActiveClue'
|
import ActiveClue from './components/ActiveClue'
|
||||||
import Header from './components/Header'
|
import Header from './components/Header'
|
||||||
import InfoBar from './components/InfoBar'
|
import InfoBar from './components/InfoBar'
|
||||||
@@ -25,6 +26,7 @@ import { useNarrator } from './hooks/useNarrator'
|
|||||||
import { useSounds } from './hooks/useSounds'
|
import { useSounds } from './hooks/useSounds'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
import { generateShareText } from './share'
|
import { generateShareText } from './share'
|
||||||
|
import * as api from './api'
|
||||||
import * as offlineStore from './offline'
|
import * as offlineStore from './offline'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -33,6 +35,7 @@ export default function App() {
|
|||||||
const error = useStore((s) => s.error)
|
const error = useStore((s) => s.error)
|
||||||
const solved = useStore((s) => s.solved)
|
const solved = useStore((s) => s.solved)
|
||||||
const inputMode = useStore((s) => s.inputMode)
|
const inputMode = useStore((s) => s.inputMode)
|
||||||
|
const setInputMode = useStore((s) => s.setInputMode)
|
||||||
const fetchSession = useStore((s) => s.fetchSession)
|
const fetchSession = useStore((s) => s.fetchSession)
|
||||||
const loadByShortId = useStore((s) => s.loadByShortId)
|
const loadByShortId = useStore((s) => s.loadByShortId)
|
||||||
const checkPendingReceipts = useStore((s) => s.checkPendingReceipts)
|
const checkPendingReceipts = useStore((s) => s.checkPendingReceipts)
|
||||||
@@ -307,6 +310,17 @@ export default function App() {
|
|||||||
<div className="grid-column">
|
<div className="grid-column">
|
||||||
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
|
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
|
||||||
<Grid />
|
<Grid />
|
||||||
|
<ClueNav />
|
||||||
|
<div className="game-input-mode" role="group" aria-label={t('settings.display.inputMode')}>
|
||||||
|
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
|
||||||
|
<button key={m} type="button"
|
||||||
|
className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`}
|
||||||
|
aria-pressed={inputMode === m}
|
||||||
|
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
|
||||||
|
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{inputMode !== 'keyboard' && <ZoomStrip onDone={() => {}} />}
|
{inputMode !== 'keyboard' && <ZoomStrip onDone={() => {}} />}
|
||||||
<h2 className="visually-hidden">{t('app.gameControls')}</h2>
|
<h2 className="visually-hidden">{t('app.gameControls')}</h2>
|
||||||
<InfoBar />
|
<InfoBar />
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function AchievementsPanel({ onClose }: { onClose: () => void })
|
|||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={`ach-badge ${a.unlocked ? 'ach-unlocked' : 'ach-locked'}`}
|
className={`ach-badge ${a.unlocked ? 'ach-unlocked' : 'ach-locked'}`}
|
||||||
title={`${a.name}: ${a.description}`}
|
aria-label={`${a.name}: ${a.unlocked ? 'Unlocked' : 'Locked'}. ${a.description}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="ach-icon"
|
className="ach-icon"
|
||||||
@@ -114,7 +114,7 @@ export default function AchievementsPanel({ onClose }: { onClose: () => void })
|
|||||||
background: `linear-gradient(135deg, ${a.color1}, ${a.color2})`,
|
background: `linear-gradient(135deg, ${a.color1}, ${a.color2})`,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ach-info">
|
<div className="ach-info">
|
||||||
<div className="ach-name">{a.name}</div>
|
<div className="ach-name">{a.name}</div>
|
||||||
|
|||||||
@@ -249,7 +249,8 @@ function Dialog({ title, children, onClose, actions }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="settings-overlay" onClick={onClose}>
|
<div className="settings-overlay" onClick={onClose}>
|
||||||
<div className="admin-dialog" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}>
|
<div className="admin-dialog" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}
|
||||||
|
onKeyDown={e => { if (e.key === 'Escape') onClose() }}>
|
||||||
<div className="admin-dialog-header">
|
<div className="admin-dialog-header">
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Close"><IconX size={14} aria-hidden="true" /></button>
|
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Close"><IconX size={14} aria-hidden="true" /></button>
|
||||||
@@ -729,18 +730,18 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="admin-login-form">
|
<div className="admin-login-form">
|
||||||
<h3 style={{ margin: '0 0 4px', fontSize: '0.85rem' }}>{t('admin.setup.title')}</h3>
|
<h3 style={{ margin: '0 0 4px', fontSize: '0.85rem' }}>{t('admin.setup.title')}</h3>
|
||||||
<p style={{ margin: '0 0 12px', fontSize: '0.7rem', opacity: 0.7 }}>{t('admin.setup.desc')}</p>
|
<p style={{ margin: '0 0 12px', fontSize: '0.7rem', opacity: 0.7 }}>{t('admin.setup.desc')}</p>
|
||||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder={t('admin.username')} className="admin-input" autoComplete="username" />
|
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder={t('admin.username')} aria-label={t('admin.username')} className="admin-input" autoComplete="username" />
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={t('admin.password')} className="admin-input" autoComplete="new-password" />
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={t('admin.password')} aria-label={t('admin.password')} className="admin-input" autoComplete="new-password" />
|
||||||
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSetup()} placeholder={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" />
|
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSetup()} placeholder={t('admin.setup.confirmPassword')} aria-label={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" />
|
||||||
{setupError && <p className="admin-login-error">{setupError}</p>}
|
{setupError && <p className="admin-login-error" role="alert">{setupError}</p>}
|
||||||
<button className="btn btn-primary" onClick={handleSetup} style={{ width: '100%' }}>{t('admin.setup.createAccount')}</button>
|
<button className="btn btn-primary" onClick={handleSetup} style={{ width: '100%' }}>{t('admin.setup.createAccount')}</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-login-form">
|
<div className="admin-login-form">
|
||||||
{setupSuccess && <p style={{ color: 'var(--color-correct)', fontSize: '0.7rem', margin: '0 0 8px' }}>{setupSuccess}</p>}
|
{setupSuccess && <p style={{ color: 'var(--color-correct)', fontSize: '0.7rem', margin: '0 0 8px' }} role="status">{setupSuccess}</p>}
|
||||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.username')} className="admin-input" autoComplete="username" />
|
<input type="text" value={username} onChange={e => setUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.username')} aria-label={t('admin.username')} className="admin-input" autoComplete="username" />
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.password')} className="admin-input" autoComplete="current-password" />
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.password')} aria-label={t('admin.password')} className="admin-input" autoComplete="current-password" />
|
||||||
{loginError && <p className="admin-login-error">{loginError}</p>}
|
{loginError && <p className="admin-login-error" role="alert">{loginError}</p>}
|
||||||
<button className="btn btn-primary" onClick={login} style={{ width: '100%' }}>{t('common.signIn')}</button>
|
<button className="btn btn-primary" onClick={login} style={{ width: '100%' }}>{t('common.signIn')}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -772,12 +773,12 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notification && <div className="admin-notification">{notification}</div>}
|
{notification && <div className="admin-notification" role="status" aria-live="polite">{notification}</div>}
|
||||||
|
|
||||||
<div className="admin-body">
|
<div className="admin-body">
|
||||||
<nav className="admin-nav" aria-label="Admin sections">
|
<nav className="admin-nav" aria-label="Admin sections" role="tablist">
|
||||||
{tabs.map(([key, label, Icon]) => (
|
{tabs.map(([key, label, Icon]) => (
|
||||||
<button key={key} className={`admin-nav-item ${tab === key ? 'admin-nav-active' : ''}`} onClick={() => setTab(key)}>
|
<button key={key} role="tab" aria-selected={tab === key} aria-current={tab === key ? 'page' : undefined} className={`admin-nav-item ${tab === key ? 'admin-nav-active' : ''}`} onClick={() => setTab(key)}>
|
||||||
<Icon size={14} /> {label}
|
<Icon size={14} /> {label}
|
||||||
{key === 'content' && stats && (stats.community_clues?.pending > 0 || stats.pending_reports > 0) && (
|
{key === 'content' && stats && (stats.community_clues?.pending > 0 || stats.pending_reports > 0) && (
|
||||||
<span className="admin-badge">{(stats.community_clues?.pending || 0) + (stats.pending_reports || 0)}</span>
|
<span className="admin-badge">{(stats.community_clues?.pending || 0) + (stats.pending_reports || 0)}</span>
|
||||||
@@ -843,7 +844,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
{(['all', 'daily', 'custom'] as const).map(f => (
|
{(['all', 'daily', 'custom'] as const).map(f => (
|
||||||
<button key={f} className={`btn btn-sm ${puzzleFilter === f ? 'btn-primary' : ''}`} onClick={() => setPuzzleFilter(f)}>
|
<button key={f} className={`btn btn-sm ${puzzleFilter === f ? 'btn-primary' : ''}`} aria-pressed={puzzleFilter === f} onClick={() => setPuzzleFilter(f)}>
|
||||||
{t(`admin.content.filter_${f}`)}
|
{t(`admin.content.filter_${f}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -854,7 +855,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" checked={puzzles.length > 0 && selectedPuzzles.size === puzzles.length} onChange={e => { if (e.target.checked) setSelectedPuzzles(new Set(puzzles.map(p => p.id))); else setSelectedPuzzles(new Set()) }} /></th><th>{t('admin.content.id')}</th><th>{t('admin.content.type')}</th><th>{t('admin.content.size')}</th><th>{t('admin.overview.solves')}</th><th>{t('admin.content.rating')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody>
|
<table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" aria-label="Select all" checked={puzzles.length > 0 && selectedPuzzles.size === puzzles.length} onChange={e => { if (e.target.checked) setSelectedPuzzles(new Set(puzzles.map(p => p.id))); else setSelectedPuzzles(new Set()) }} /></th><th>{t('admin.content.id')}</th><th>{t('admin.content.type')}</th><th>{t('admin.content.size')}</th><th>{t('admin.overview.solves')}</th><th>{t('admin.content.rating')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody>
|
||||||
{puzzles.map(p => (
|
{puzzles.map(p => (
|
||||||
<tr key={p.id}>
|
<tr key={p.id}>
|
||||||
<td><input type="checkbox" checked={selectedPuzzles.has(p.id)} onChange={() => togglePuzzleSelect(p.id)} /></td>
|
<td><input type="checkbox" checked={selectedPuzzles.has(p.id)} onChange={() => togglePuzzleSelect(p.id)} /></td>
|
||||||
@@ -864,7 +865,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<td>{p.solves}</td>
|
<td>{p.solves}</td>
|
||||||
<td>{p.rating > 0 ? `${p.rating}/5` : '-'}</td>
|
<td>{p.rating > 0 ? `${p.rating}/5` : '-'}</td>
|
||||||
<td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td>
|
<td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
<td><button className="cc-delete" onClick={() => deletePuzzle(p.id)}><IconTrashFilled size={11} /></button></td>
|
<td><button className="cc-delete" aria-label="Delete puzzle" onClick={() => deletePuzzle(p.id)}><IconTrashFilled size={11} aria-hidden="true" /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
@@ -879,7 +880,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" checked={communityPuzzles.length > 0 && selectedCommunity.size === communityPuzzles.length} onChange={e => { if (e.target.checked) setSelectedCommunity(new Set(communityPuzzles.map(p => p.id))); else setSelectedCommunity(new Set()) }} /></th><th>{t('admin.content.title')}</th><th>{t('admin.content.author')}</th><th>{t('admin.content.size')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody>
|
<table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" aria-label="Select all" checked={communityPuzzles.length > 0 && selectedCommunity.size === communityPuzzles.length} onChange={e => { if (e.target.checked) setSelectedCommunity(new Set(communityPuzzles.map(p => p.id))); else setSelectedCommunity(new Set()) }} /></th><th>{t('admin.content.title')}</th><th>{t('admin.content.author')}</th><th>{t('admin.content.size')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody>
|
||||||
{communityPuzzles.map(p => (
|
{communityPuzzles.map(p => (
|
||||||
<tr key={p.id}>
|
<tr key={p.id}>
|
||||||
<td><input type="checkbox" checked={selectedCommunity.has(p.id)} onChange={() => toggleCommunitySelect(p.id)} /></td>
|
<td><input type="checkbox" checked={selectedCommunity.has(p.id)} onChange={() => toggleCommunitySelect(p.id)} /></td>
|
||||||
@@ -887,7 +888,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<td>{p.author}</td>
|
<td>{p.author}</td>
|
||||||
<td>{p.width}x{p.height}</td>
|
<td>{p.width}x{p.height}</td>
|
||||||
<td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td>
|
<td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
<td><button className="cc-delete" onClick={() => deleteCommunityPuzzle(p.id)}><IconTrashFilled size={11} /></button></td>
|
<td><button className="cc-delete" aria-label="Delete community puzzle" onClick={() => deleteCommunityPuzzle(p.id)}><IconTrashFilled size={11} aria-hidden="true" /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
@@ -929,7 +930,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<h3 className="admin-section-heading">{t('admin.content.blockedWords')}</h3>
|
<h3 className="admin-section-heading">{t('admin.content.blockedWords')}</h3>
|
||||||
<p style={{ fontSize: '0.625rem', color: 'var(--text-tertiary)', marginBottom: 8 }}>{t('admin.content.blockedWordsDesc')}</p>
|
<p style={{ fontSize: '0.625rem', color: 'var(--text-tertiary)', marginBottom: 8 }}>{t('admin.content.blockedWordsDesc')}</p>
|
||||||
<div className="admin-webhook-form">
|
<div className="admin-webhook-form">
|
||||||
<input placeholder={t('admin.content.blockedWordPlaceholder')} value={blockedWord}
|
<input placeholder={t('admin.content.blockedWordPlaceholder')} aria-label={t('admin.content.blockedWordPlaceholder')} value={blockedWord}
|
||||||
onChange={e => setBlockedWord(e.target.value)}
|
onChange={e => setBlockedWord(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && addBlockedWord()}
|
onKeyDown={e => e.key === 'Enter' && addBlockedWord()}
|
||||||
className="admin-input" style={{ maxWidth: '16rem' }} />
|
className="admin-input" style={{ maxWidth: '16rem' }} />
|
||||||
@@ -941,7 +942,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{tab === 'users' && tabLoading.users && <AdminSpinner label={t('common.loading')} />}
|
{tab === 'users' && tabLoading.users && <AdminSpinner label={t('common.loading')} />}
|
||||||
{tab === 'users' && !tabLoading.users && (<>
|
{tab === 'users' && !tabLoading.users && (<>
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<input placeholder={t('admin.users.searchPlaceholder')} value={sessionSearch} onChange={e => setSessionSearch(e.target.value)} className="admin-input" style={{ maxWidth: '16rem' }} />
|
<input placeholder={t('admin.users.searchPlaceholder')} aria-label={t('admin.users.searchPlaceholder')} value={sessionSearch} onChange={e => setSessionSearch(e.target.value)} className="admin-input" style={{ maxWidth: '16rem' }} />
|
||||||
<button className="btn btn-sm" onClick={purgeSessions}>{t('admin.users.purgeInactive')}</button>
|
<button className="btn btn-sm" onClick={purgeSessions}>{t('admin.users.purgeInactive')}</button>
|
||||||
<span className="admin-toolbar-count">{t('admin.users.userCount', { count: filteredSessions.length })}</span>
|
<span className="admin-toolbar-count">{t('admin.users.userCount', { count: filteredSessions.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -956,10 +957,10 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<td className="admin-mono">{new Date(s.created_at).toLocaleDateString()}</td>
|
<td className="admin-mono">{new Date(s.created_at).toLocaleDateString()}</td>
|
||||||
<td style={{ display: 'flex', gap: 4 }}>
|
<td style={{ display: 'flex', gap: 4 }}>
|
||||||
{s.banned
|
{s.banned
|
||||||
? <Tooltip text={t('admin.users.unban')}><button className="btn btn-sm" onClick={() => unbanUser(s.token)}><IconCircleCheckFilled size={11} /></button></Tooltip>
|
? <Tooltip text={t('admin.users.unban')}><button className="btn btn-sm" aria-label={t('admin.users.unban')} onClick={() => unbanUser(s.token)}><IconCircleCheckFilled size={11} aria-hidden="true" /></button></Tooltip>
|
||||||
: <Tooltip text={t('admin.users.ban')}><button className="cc-delete" onClick={() => banUser(s.token, s.display_name ?? '')}><IconBan size={11} /></button></Tooltip>
|
: <Tooltip text={t('admin.users.ban')}><button className="cc-delete" aria-label={t('admin.users.ban')} onClick={() => banUser(s.token, s.display_name ?? '')}><IconBan size={11} aria-hidden="true" /></button></Tooltip>
|
||||||
}
|
}
|
||||||
<Tooltip text={t('common.delete')}><button className="cc-delete" onClick={() => deleteSession(s.token)}><IconTrashFilled size={11} /></button></Tooltip>
|
<Tooltip text={t('common.delete')}><button className="cc-delete" aria-label={t('common.delete')} onClick={() => deleteSession(s.token)}><IconTrashFilled size={11} aria-hidden="true" /></button></Tooltip>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -979,7 +980,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{tab === 'integrations' && !tabLoading.integrations && (<>
|
{tab === 'integrations' && !tabLoading.integrations && (<>
|
||||||
<h3 className="admin-section-heading">{t('admin.integrations.webhooks')}</h3>
|
<h3 className="admin-section-heading">{t('admin.integrations.webhooks')}</h3>
|
||||||
<div className="admin-webhook-form">
|
<div className="admin-webhook-form">
|
||||||
<input placeholder={t('admin.integrations.webhookUrl')} value={newWebhook.url} onChange={e => setNewWebhook({ ...newWebhook, url: e.target.value })} className="admin-input" />
|
<input placeholder={t('admin.integrations.webhookUrl')} aria-label={t('admin.integrations.webhookUrl')} value={newWebhook.url} onChange={e => setNewWebhook({ ...newWebhook, url: e.target.value })} className="admin-input" />
|
||||||
<Select value={newWebhook.events} onChange={v => setNewWebhook({ ...newWebhook, events: String(v) })} options={[
|
<Select value={newWebhook.events} onChange={v => setNewWebhook({ ...newWebhook, events: String(v) })} options={[
|
||||||
{ value: 'daily_puzzle.published', label: t('admin.integrations.dailyPublished') },
|
{ value: 'daily_puzzle.published', label: t('admin.integrations.dailyPublished') },
|
||||||
{ value: 'puzzle.completed', label: t('admin.integrations.puzzleCompleted') },
|
{ value: 'puzzle.completed', label: t('admin.integrations.puzzleCompleted') },
|
||||||
@@ -1034,6 +1035,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={rulesRef}
|
ref={rulesRef}
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
|
aria-label="Instance rules"
|
||||||
rows={6}
|
rows={6}
|
||||||
value={String(config?.instance_rules ?? '')}
|
value={String(config?.instance_rules ?? '')}
|
||||||
onChange={e => setConfig(c => ({ ...(c ?? {}), instance_rules: e.target.value }))}
|
onChange={e => setConfig(c => ({ ...(c ?? {}), instance_rules: e.target.value }))}
|
||||||
@@ -1078,7 +1080,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.actorUsername')}</label>
|
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.actorUsername')}</label>
|
||||||
<input className="admin-input" value={profile.ap_actor_name} onChange={e => updateProfile('ap_actor_name', e.target.value)} placeholder="crossword" />
|
<input className="admin-input" value={profile.ap_actor_name} onChange={e => updateProfile('ap_actor_name', e.target.value)} placeholder="crossword" />
|
||||||
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.bio')}</label>
|
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.bio')}</label>
|
||||||
<textarea className="admin-input" rows={4} value={profile.ap_bio} onChange={e => updateProfile('ap_bio', e.target.value)} placeholder={t('admin.federation.bioPlaceholder')} style={{ resize: 'vertical', fontFamily: 'inherit' }} />
|
<textarea className="admin-input" rows={4} value={profile.ap_bio} onChange={e => updateProfile('ap_bio', e.target.value)} aria-label={t('admin.federation.bioPlaceholder')} placeholder={t('admin.federation.bioPlaceholder')} style={{ resize: 'vertical', fontFamily: 'inherit' }} />
|
||||||
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.avatar')}</label>
|
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.avatar')}</label>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{avatarPreview && <img src={avatarPreview} alt="avatar" style={{ width: 48, height: 48, borderRadius: 4, objectFit: 'cover' }} />}
|
{avatarPreview && <img src={avatarPreview} alt="avatar" style={{ width: 48, height: 48, borderRadius: 4, objectFit: 'cover' }} />}
|
||||||
@@ -1122,7 +1124,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td className="admin-mono" style={{ maxWidth: '24rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</td>
|
<td className="admin-mono" style={{ maxWidth: '24rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</td>
|
||||||
<td className="admin-mono">{new Date(item.published).toLocaleString()}</td>
|
<td className="admin-mono">{new Date(item.published).toLocaleString()}</td>
|
||||||
<td><button className="cc-delete" onClick={() => {
|
<td><button className="cc-delete" aria-label="Delete post" onClick={() => {
|
||||||
setDialog({ title: 'Delete post', message: `Delete "${label}" and send Delete activity to all followers?`, onConfirm: async () => {
|
setDialog({ title: 'Delete post', message: `Delete "${label}" and send Delete activity to all followers?`, onConfirm: async () => {
|
||||||
await adminFetch(`/outbox/${i}`, { method: 'DELETE' })
|
await adminFetch(`/outbox/${i}`, { method: 'DELETE' })
|
||||||
setOutboxItems(prev => prev.filter((_, j) => j !== i))
|
setOutboxItems(prev => prev.filter((_, j) => j !== i))
|
||||||
@@ -1137,7 +1139,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-webhook-form" style={{ marginTop: 8 }}>
|
<div className="admin-webhook-form" style={{ marginTop: 8 }}>
|
||||||
<input placeholder="Post URI to delete from remote instances" value={deleteUri}
|
<input placeholder="Post URI to delete from remote instances" aria-label="Post URI to delete from remote instances" value={deleteUri}
|
||||||
onChange={e => setDeleteUri(e.target.value)}
|
onChange={e => setDeleteUri(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && deleteUri.trim() && (async () => {
|
onKeyDown={e => e.key === 'Enter' && deleteUri.trim() && (async () => {
|
||||||
await adminFetch('/outbox/delete-remote', { method: 'POST', body: JSON.stringify({ uri: deleteUri.trim() }) })
|
await adminFetch('/outbox/delete-remote', { method: 'POST', body: JSON.stringify({ uri: deleteUri.trim() }) })
|
||||||
@@ -1154,7 +1156,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{/* Peers */}
|
{/* Peers */}
|
||||||
<h3 className="admin-section-heading">{t('admin.federation.peersTitle')}</h3>
|
<h3 className="admin-section-heading">{t('admin.federation.peersTitle')}</h3>
|
||||||
<div className="admin-webhook-form">
|
<div className="admin-webhook-form">
|
||||||
<input placeholder={t('admin.federation.peerDomainPlaceholder')} value={newPeerDomain}
|
<input placeholder={t('admin.federation.peerDomainPlaceholder')} aria-label={t('admin.federation.peerDomainPlaceholder')} value={newPeerDomain}
|
||||||
onChange={e => setNewPeerDomain(e.target.value)}
|
onChange={e => setNewPeerDomain(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && connectPeer()}
|
onKeyDown={e => e.key === 'Enter' && connectPeer()}
|
||||||
className="admin-input" />
|
className="admin-input" />
|
||||||
@@ -1167,7 +1169,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<tr key={p.domain}>
|
<tr key={p.domain}>
|
||||||
<td className="admin-mono">{p.domain}</td>
|
<td className="admin-mono">{p.domain}</td>
|
||||||
<td className="admin-mono">{p.last_seen ? new Date(p.last_seen).toLocaleString() : 'never'}</td>
|
<td className="admin-mono">{p.last_seen ? new Date(p.last_seen).toLocaleString() : 'never'}</td>
|
||||||
<td><button className="cc-delete" onClick={() => disconnectPeer(p.domain)}><IconTrashFilled size={11} /></button></td>
|
<td><button className="cc-delete" aria-label="Disconnect peer" onClick={() => disconnectPeer(p.domain)}><IconTrashFilled size={11} aria-hidden="true" /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}</tbody>
|
))}</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1185,8 +1187,8 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<td className="admin-mono" style={{ maxWidth: '20rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.actor_uri}</td>
|
<td className="admin-mono" style={{ maxWidth: '20rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.actor_uri}</td>
|
||||||
<td className="admin-mono">{new Date(f.created_at).toLocaleDateString()}</td>
|
<td className="admin-mono">{new Date(f.created_at).toLocaleDateString()}</td>
|
||||||
<td style={{ display: 'flex', gap: 4 }}>
|
<td style={{ display: 'flex', gap: 4 }}>
|
||||||
<button className="btn btn-sm" onClick={() => approveFollower(f.actor_uri)}><IconCheckFilled size={11} /></button>
|
<button className="btn btn-sm" aria-label="Approve follower" onClick={() => approveFollower(f.actor_uri)}><IconCheckFilled size={11} aria-hidden="true" /></button>
|
||||||
<button className="cc-delete" onClick={() => rejectFollower(f.actor_uri)}><IconSquareXFilled size={11} /></button>
|
<button className="cc-delete" aria-label="Reject follower" onClick={() => rejectFollower(f.actor_uri)}><IconSquareXFilled size={11} aria-hidden="true" /></button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}</tbody>
|
))}</tbody>
|
||||||
@@ -1196,7 +1198,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{/* Security */}
|
{/* Security */}
|
||||||
<h3 className="admin-section-heading">{t('admin.federation.security')}</h3>
|
<h3 className="admin-section-heading">{t('admin.federation.security')}</h3>
|
||||||
<div className="admin-webhook-form">
|
<div className="admin-webhook-form">
|
||||||
<input placeholder={t('admin.federation.domainToBlock')} value={blockDomain} onChange={e => setBlockDomain(e.target.value)} className="admin-input" />
|
<input placeholder={t('admin.federation.domainToBlock')} aria-label={t('admin.federation.domainToBlock')} value={blockDomain} onChange={e => setBlockDomain(e.target.value)} className="admin-input" />
|
||||||
<input placeholder={t('admin.federation.reasonOptional')} value={blockReason} onChange={e => setBlockReason(e.target.value)} className="admin-input" style={{ maxWidth: '12rem' }} />
|
<input placeholder={t('admin.federation.reasonOptional')} value={blockReason} onChange={e => setBlockReason(e.target.value)} className="admin-input" style={{ maxWidth: '12rem' }} />
|
||||||
<button className="btn btn-sm" onClick={blockInstance} disabled={!blockDomain.trim()}>{t('common.block')}</button>
|
<button className="btn btn-sm" onClick={blockInstance} disabled={!blockDomain.trim()}>{t('common.block')}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1265,6 +1267,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{t('admin.federation.dailyTaglines')} ({apTaglines.trim() ? apTaglines.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
|
{t('admin.federation.dailyTaglines')} ({apTaglines.trim() ? apTaglines.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
|
||||||
</label>
|
</label>
|
||||||
<textarea className="admin-input" rows={4} value={apTaglines} onChange={e => setApTaglines(e.target.value)}
|
<textarea className="admin-input" rows={4} value={apTaglines} onChange={e => setApTaglines(e.target.value)}
|
||||||
|
aria-label={t('admin.federation.dailyTaglines')}
|
||||||
placeholder={t('admin.federation.onePerLine')}
|
placeholder={t('admin.federation.onePerLine')}
|
||||||
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
|
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
|
||||||
|
|
||||||
@@ -1272,6 +1275,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{t('admin.federation.dailySignoffs')} ({apSignoffs.trim() ? apSignoffs.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
|
{t('admin.federation.dailySignoffs')} ({apSignoffs.trim() ? apSignoffs.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
|
||||||
</label>
|
</label>
|
||||||
<textarea className="admin-input" rows={4} value={apSignoffs} onChange={e => setApSignoffs(e.target.value)}
|
<textarea className="admin-input" rows={4} value={apSignoffs} onChange={e => setApSignoffs(e.target.value)}
|
||||||
|
aria-label={t('admin.federation.dailySignoffs')}
|
||||||
placeholder={t('admin.federation.onePerLine')}
|
placeholder={t('admin.federation.onePerLine')}
|
||||||
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
|
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
|
||||||
|
|
||||||
@@ -1279,6 +1283,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{t('admin.federation.communityMiddles')} ({apCommunityMiddles.trim() ? apCommunityMiddles.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
|
{t('admin.federation.communityMiddles')} ({apCommunityMiddles.trim() ? apCommunityMiddles.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
|
||||||
</label>
|
</label>
|
||||||
<textarea className="admin-input" rows={4} value={apCommunityMiddles} onChange={e => setApCommunityMiddles(e.target.value)}
|
<textarea className="admin-input" rows={4} value={apCommunityMiddles} onChange={e => setApCommunityMiddles(e.target.value)}
|
||||||
|
aria-label={t('admin.federation.communityMiddles')}
|
||||||
placeholder={t('admin.federation.onePerLine')}
|
placeholder={t('admin.federation.onePerLine')}
|
||||||
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
|
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ function SmoothLineChart({ data, width, height, formatY, label, maxYOverride }:
|
|||||||
const gradId = `smooth-grad-${label || 'default'}`
|
const gradId = `smooth-grad-${label || 'default'}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg">
|
<>
|
||||||
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"
|
||||||
|
role="img" aria-label={label ? `Line chart of ${label} over time` : 'Line chart'}>
|
||||||
{label && (
|
{label && (
|
||||||
<text x={pad.left} y={10} className="analytics-chart-label">{label}</text>
|
<text x={pad.left} y={10} className="analytics-chart-label">{label}</text>
|
||||||
)}
|
)}
|
||||||
@@ -205,6 +207,10 @@ function SmoothLineChart({ data, width, height, formatY, label, maxYOverride }:
|
|||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
<span className="visually-hidden">
|
||||||
|
{data.map(d => d.label ? `${d.label}: ${formatY(d.y)}` : formatY(d.y)).join('; ')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +238,9 @@ function BarChart({ data, labels, width, height, highlightMax, highlightMin, for
|
|||||||
const gap = w / data.length
|
const gap = w / data.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg">
|
<>
|
||||||
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"
|
||||||
|
role="img" aria-label="Bar chart of values">
|
||||||
{data.map((val, i) => {
|
{data.map((val, i) => {
|
||||||
const barH = (val / maxVal) * h
|
const barH = (val / maxVal) * h
|
||||||
const x = pad.left + i * gap + (gap - barW) / 2
|
const x = pad.left + i * gap + (gap - barW) / 2
|
||||||
@@ -257,6 +265,10 @@ function BarChart({ data, labels, width, height, highlightMax, highlightMin, for
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
<span className="visually-hidden">
|
||||||
|
{data.map((val, i) => labels[i] ? `${labels[i]}: ${val}` : String(val)).join('; ')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +291,7 @@ function Histogram({ buckets, minVal, maxVal, width, height }: {
|
|||||||
const gap = w / buckets.length
|
const gap = w / buckets.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg">
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg" role="img" aria-label="Distribution of solve times">
|
||||||
{buckets.map((val, i) => {
|
{buckets.map((val, i) => {
|
||||||
const barH = (val / peak) * h
|
const barH = (val / peak) * h
|
||||||
const x = pad.left + i * gap + (gap - barW) / 2
|
const x = pad.left + i * gap + (gap - barW) / 2
|
||||||
@@ -336,7 +348,7 @@ function DonutChart({ segments, size }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analytics-donut">
|
<div className="analytics-donut">
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Proportional distribution chart">
|
||||||
{arcs.map((a, i) => (
|
{arcs.map((a, i) => (
|
||||||
<circle key={i} cx={cx} cy={cy} r={innerR}
|
<circle key={i} cx={cx} cy={cy} r={innerR}
|
||||||
fill="none" stroke={a.color} strokeWidth={strokeW}
|
fill="none" stroke={a.color} strokeWidth={strokeW}
|
||||||
@@ -386,7 +398,7 @@ function GaugeChart({ value, size }: { value: number; size: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analytics-gauge">
|
<div className="analytics-gauge">
|
||||||
<svg width={size} height={size * 0.7} viewBox={`0 0 ${size} ${size * 0.7}`}>
|
<svg width={size} height={size * 0.7} viewBox={`0 0 ${size} ${size * 0.7}`} role="img" aria-label="Completion gauge">
|
||||||
<path d={arcPath(startAngle, endAngle)} fill="none"
|
<path d={arcPath(startAngle, endAngle)} fill="none"
|
||||||
stroke="var(--border)" strokeWidth={8} strokeLinecap="round" />
|
stroke="var(--border)" strokeWidth={8} strokeLinecap="round" />
|
||||||
{value > 0 && (
|
{value > 0 && (
|
||||||
@@ -450,7 +462,7 @@ function ActivityHeatmap({ calendar }: { calendar: Record<string, number> }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<svg width="100%" viewBox={`0 0 ${svgW} ${svgH}`} className="analytics-chart-svg">
|
<svg width="100%" viewBox={`0 0 ${svgW} ${svgH}`} className="analytics-chart-svg" role="img" aria-label="Solves per day heatmap">
|
||||||
{months.map((m, i) => (
|
{months.map((m, i) => (
|
||||||
<text key={i} x={24 + m.col * total} y={8}
|
<text key={i} x={24 + m.col * total} y={8}
|
||||||
className="analytics-chart-tick">{m.label}</text>
|
className="analytics-chart-tick">{m.label}</text>
|
||||||
@@ -535,7 +547,7 @@ export default function AnalyticsDashboard({ onClose }: { onClose: () => void })
|
|||||||
|
|
||||||
const presetDonutData = useMemo(() => {
|
const presetDonutData = useMemo(() => {
|
||||||
if (!data?.by_preset) return []
|
if (!data?.by_preset) return []
|
||||||
const colors = ['var(--accent)', 'rgba(212,67,47,0.7)', 'rgba(212,67,47,0.45)', 'rgba(212,67,47,0.25)', 'rgba(212,67,47,0.12)']
|
const colors = ['var(--accent)', 'color-mix(in srgb, var(--accent) 70%, var(--bg))', 'color-mix(in srgb, var(--accent) 50%, var(--bg))', 'color-mix(in srgb, var(--accent) 30%, var(--bg))', 'color-mix(in srgb, var(--accent) 15%, var(--bg))']
|
||||||
return presetOrder
|
return presetOrder
|
||||||
.filter(k => data.by_preset[k]?.count > 0)
|
.filter(k => data.by_preset[k]?.count > 0)
|
||||||
.map((k, i) => ({
|
.map((k, i) => ({
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
|||||||
const puzzle = useStore((s) => s.puzzle)
|
const puzzle = useStore((s) => s.puzzle)
|
||||||
const [flips, setFlips] = useState<FlipItem[]>([])
|
const [flips, setFlips] = useState<FlipItem[]>([])
|
||||||
const prevEntries = useRef<string[][]>([])
|
const prevEntries = useRef<string[][]>([])
|
||||||
|
const timeouts = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
|
||||||
|
useEffect(() => () => { timeouts.current.forEach(id => clearTimeout(id)) }, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!puzzle) return
|
if (!puzzle) return
|
||||||
@@ -50,7 +53,7 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFlips(f => [...f, ...newFlips])
|
setFlips(f => [...f, ...newFlips])
|
||||||
setTimeout(() => {
|
timeouts.current.push(setTimeout(() => {
|
||||||
// restore SVG letter visibility
|
// restore SVG letter visibility
|
||||||
if (svgRef.current) {
|
if (svgRef.current) {
|
||||||
for (const flip of newFlips) {
|
for (const flip of newFlips) {
|
||||||
@@ -59,7 +62,7 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFlips(f => f.filter(fl => !newFlips.some(n => n.id === fl.id)))
|
setFlips(f => f.filter(fl => !newFlips.some(n => n.id === fl.id)))
|
||||||
}, 600)
|
}, 600))
|
||||||
}
|
}
|
||||||
}, [entries, puzzle, svgRef])
|
}, [entries, puzzle, svgRef])
|
||||||
|
|
||||||
@@ -92,13 +95,13 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
|||||||
{flip.clueNumber && (
|
{flip.clueNumber && (
|
||||||
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
|
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
|
||||||
)}
|
)}
|
||||||
{flip.oldLetter && <span className="flip-letter" style={{ fontSize }}>{flip.oldLetter}</span>}
|
{flip.oldLetter && <span className="flip-letter" style={{ fontSize }} aria-hidden="true">{flip.oldLetter}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flip-face flip-back">
|
<div className="flip-face flip-back">
|
||||||
{flip.clueNumber && (
|
{flip.clueNumber && (
|
||||||
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
|
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
|
||||||
)}
|
)}
|
||||||
<span className="flip-letter" style={{ fontSize }}>{flip.newLetter}</span>
|
<span className="flip-letter" style={{ fontSize }} aria-hidden="true">{flip.newLetter}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
|
||||||
|
export default function ClueNav() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const puzzle = useStore((s) => s.puzzle)
|
||||||
|
const activeClue = useStore((s) => s.activeClue)
|
||||||
|
const direction = useStore((s) => s.direction)
|
||||||
|
const tabClue = useStore((s) => s.tabClue)
|
||||||
|
|
||||||
|
if (!puzzle || activeClue === null) return null
|
||||||
|
|
||||||
|
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
|
||||||
|
const clue = clues.find((c) => c.number === activeClue)
|
||||||
|
const current = clue ? `${activeClue} ${t(direction === 'across' ? 'game.across' : 'game.down')}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="clue-nav" role="group" aria-label={t('a11y.clueNavigation')}>
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => tabClue(false)} aria-label={t('a11y.previousClue')}>
|
||||||
|
<IconArrowLeft size={14} aria-hidden="true" /> {t('a11y.previousClue')}
|
||||||
|
</button>
|
||||||
|
<span className="clue-nav-current">{current}</span>
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => tabClue(true)} aria-label={t('a11y.nextClue')}>
|
||||||
|
{t('a11y.nextClue')} <IconArrowRight size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ export default function CluePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`clue-panel ${solved ? 'clue-panel-solved' : ''}`} role="region" aria-label="Clues" aria-live="polite">
|
<div className={`clue-panel ${solved ? 'clue-panel-solved' : ''}`} role="region" aria-label="Clues">
|
||||||
<div className="clue-tabs" role="tablist">
|
<div className="clue-tabs" role="tablist">
|
||||||
<button
|
<button
|
||||||
className={`clue-tab ${viewTab === 'across' ? 'active' : ''}`}
|
className={`clue-tab ${viewTab === 'across' ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function ClueTrainer({ onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
|
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
|
||||||
<IconX size={16} />
|
<IconX size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +98,7 @@ export default function ClueTrainer({ onClose }: Props) {
|
|||||||
{(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => (
|
{(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => (
|
||||||
<button key={d}
|
<button key={d}
|
||||||
className={`trainer-diff-btn ${difficulty === d ? 'trainer-diff-active' : ''}`}
|
className={`trainer-diff-btn ${difficulty === d ? 'trainer-diff-active' : ''}`}
|
||||||
|
aria-pressed={difficulty === d}
|
||||||
onClick={() => changeDifficulty(d)}>
|
onClick={() => changeDifficulty(d)}>
|
||||||
{t(`trainer.${d}`)}
|
{t(`trainer.${d}`)}
|
||||||
</button>
|
</button>
|
||||||
@@ -143,7 +144,7 @@ export default function ClueTrainer({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className={`trainer-result ${result.correct ? 'trainer-correct' : 'trainer-wrong'}`}>
|
<div className={`trainer-result ${result.correct ? 'trainer-correct' : 'trainer-wrong'}`} role="status">
|
||||||
{result.correct ? (
|
{result.correct ? (
|
||||||
<><IconCheck size={16} /> {t('trainer.correctMsg')}</>
|
<><IconCheck size={16} /> {t('trainer.correctMsg')}</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ export default function CommunityCluesPanel({ onClose }: { onClose: () => void }
|
|||||||
onChange={e => setWord(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))}
|
onChange={e => setWord(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))}
|
||||||
onKeyDown={e => e.key === 'Enter' && search()}
|
onKeyDown={e => e.key === 'Enter' && search()}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()}>
|
<button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()} aria-label="Search">
|
||||||
<IconSearch size={14} />
|
<IconSearch size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ export default function CommunityCluesPanel({ onClose }: { onClose: () => void }
|
|||||||
maxLength={500}
|
maxLength={500}
|
||||||
onKeyDown={e => e.key === 'Enter' && submitClue()}
|
onKeyDown={e => e.key === 'Enter' && submitClue()}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()}>
|
<button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()} aria-label={t('game.submit')}>
|
||||||
<IconSend2 size={14} />
|
<IconSend2 size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -43,14 +43,16 @@ export default function CommunityToday() {
|
|||||||
|
|
||||||
<div className="ed-puzzle-grid">
|
<div className="ed-puzzle-grid">
|
||||||
{puzzles.map(p => (
|
{puzzles.map(p => (
|
||||||
<button key={p.id} type="button" className="ed-puzzle-card" onClick={() => play(p.id)}>
|
<div key={p.id} className="ed-puzzle-card" role="button" tabIndex={0}
|
||||||
|
onClick={() => play(p.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(p.id) } }}>
|
||||||
<span className="ed-puzzle-size">{p.width}x{p.height}</span>
|
<span className="ed-puzzle-size">{p.width}x{p.height}</span>
|
||||||
<span className="ed-puzzle-title">{p.title}</span>
|
<span className="ed-puzzle-title">{p.title}</span>
|
||||||
<span className="ed-puzzle-by">
|
<span className="ed-puzzle-by">
|
||||||
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
<span className="ed-puzzle-report" onClick={e => e.stopPropagation()}><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,12 +45,24 @@ export default function DisambiguationPopup({ svgRef }: Props) {
|
|||||||
}
|
}
|
||||||
}, [show])
|
}, [show])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') dismissDisambiguation()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [show, dismissDisambiguation])
|
||||||
|
|
||||||
if (!show || !result || !geom) return null
|
if (!show || !result || !geom) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
className="disambiguation"
|
className="disambiguation"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Choose a handwriting match"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: geom.top,
|
top: geom.top,
|
||||||
@@ -62,6 +74,7 @@ export default function DisambiguationPopup({ svgRef }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="disambiguation-btn"
|
className="disambiguation-btn"
|
||||||
onClick={() => acceptCandidate(c.letter)}
|
onClick={() => acceptCandidate(c.letter)}
|
||||||
|
aria-label={`Select ${c.letter}`}
|
||||||
>
|
>
|
||||||
{c.letter}
|
{c.letter}
|
||||||
</button>
|
</button>
|
||||||
@@ -71,6 +84,7 @@ export default function DisambiguationPopup({ svgRef }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="disambiguation-btn dismiss"
|
className="disambiguation-btn dismiss"
|
||||||
onClick={dismissDisambiguation}
|
onClick={dismissDisambiguation}
|
||||||
|
aria-label={t('common.dismiss')}
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -414,7 +414,10 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
for (const [mr, mc] of mirrorCoords(r, c)) {
|
for (const [mr, mc] of mirrorCoords(r, c)) {
|
||||||
if (mr >= 0 && mr < height && mc >= 0 && mc < width) { next[mr][mc] = newType; if (newType === 'black') nextLetters[mr][mc] = null }
|
if (mr >= 0 && mr < height && mc >= 0 && mc < width) { next[mr][mc] = newType; if (newType === 'black') nextLetters[mr][mc] = null }
|
||||||
}
|
}
|
||||||
if (hasOffensivePattern(next)) return
|
if (hasOffensivePattern(next)) {
|
||||||
|
setWarnings([t('editor.offensivePattern')])
|
||||||
|
return
|
||||||
|
}
|
||||||
pushUndo()
|
pushUndo()
|
||||||
setCells(next); setLetters(nextLetters); setClues([])
|
setCells(next); setLetters(nextLetters); setClues([])
|
||||||
} else if (mode === 'fill') {
|
} else if (mode === 'fill') {
|
||||||
@@ -575,6 +578,62 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
const displayLetters = isTest ? testEntries : letters
|
const displayLetters = isTest ? testEntries : letters
|
||||||
const displaySelected = isTest ? testSelected : selectedCell
|
const displaySelected = isTest ? testSelected : selectedCell
|
||||||
|
|
||||||
|
// Screen-reader grid: a parallel interactive layer over the visual SVG so
|
||||||
|
// blind constructors can design, fill, and test by keyboard.
|
||||||
|
const srCellRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
|
||||||
|
const selectCellAt = (r: number, c: number) => {
|
||||||
|
if (isTest) setTestSelected({ row: r, col: c })
|
||||||
|
else setSelectedCell({ row: r, col: c })
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveGridSelection = (dr: number, dc: number) => {
|
||||||
|
const sel = displaySelected
|
||||||
|
let r = sel ? sel.row + dr : 0
|
||||||
|
let c = sel ? sel.col + dc : 0
|
||||||
|
if (r < 0 || c < 0 || r >= height || c >= width) return
|
||||||
|
if (mode !== 'design') {
|
||||||
|
while (r >= 0 && c >= 0 && r < height && c < width && cells[r]?.[c] === 'black') {
|
||||||
|
r += dr; c += dc
|
||||||
|
}
|
||||||
|
if (r < 0 || c < 0 || r >= height || c >= width) return
|
||||||
|
}
|
||||||
|
if (mode === 'fill' || mode === 'clues') {
|
||||||
|
const wordCells = getWordCells(cells, r, c, editorDirection, width, height)
|
||||||
|
setHighlightedCells(new Set(wordCells.map(([wr, wc]) => `${wr},${wc}`)))
|
||||||
|
}
|
||||||
|
selectCellAt(r, c)
|
||||||
|
srCellRefs.current[r * width + c]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSrGridKeyDown = (e: { key: string; preventDefault(): void }) => {
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); moveGridSelection(-1, 0) }
|
||||||
|
else if (e.key === 'ArrowDown') { e.preventDefault(); moveGridSelection(1, 0) }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveGridSelection(0, -1) }
|
||||||
|
else if (e.key === 'ArrowRight') { e.preventDefault(); moveGridSelection(0, 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const srGridLabel = (r: number, c: number): string => {
|
||||||
|
const isBlackCell = cells[r]?.[c] === 'black'
|
||||||
|
const num = cellNumbers[`${r},${c}`]
|
||||||
|
const letter = displayLetters[r]?.[c] || ''
|
||||||
|
let label = isBlackCell ? t('grid.blackCell') : t('grid.cellLabel', { row: r + 1, col: c + 1 })
|
||||||
|
if (num && !isBlackCell) label += t('grid.cellNumber', { num })
|
||||||
|
if (letter) label += t('grid.cellLetter', { letter })
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusableIdx = displaySelected
|
||||||
|
? displaySelected.row * width + displaySelected.col
|
||||||
|
: (() => {
|
||||||
|
for (let r = 0; r < height; r++) {
|
||||||
|
for (let c = 0; c < width; c++) {
|
||||||
|
if (cells[r]?.[c] !== 'black') return r * width + c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})()
|
||||||
|
|
||||||
if (view === 'list') {
|
if (view === 'list') {
|
||||||
return (
|
return (
|
||||||
<div className="editor-fullscreen" role="main" aria-label={t('editor.title')}>
|
<div className="editor-fullscreen" role="main" aria-label={t('editor.title')}>
|
||||||
@@ -590,9 +649,9 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="editor-custom-size">
|
<div className="editor-custom-size">
|
||||||
<label className="settings-label" style={{ marginTop: 8 }}>{t('editor.customSize')}</label>
|
<label className="settings-label" style={{ marginTop: 8 }}>{t('editor.customSize')}</label>
|
||||||
<div className="settings-row">
|
<div className="settings-row">
|
||||||
<NumberInput value={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" />
|
<NumberInput value={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" aria-label={t('editor.width')} />
|
||||||
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem', display: 'flex', alignItems: 'center' }}>x</span>
|
<span aria-hidden="true" style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem', display: 'flex', alignItems: 'center' }}>x</span>
|
||||||
<NumberInput value={customH} min={3} max={25} onChange={setCustomH} className="editor-size-input" />
|
<NumberInput value={customH} min={3} max={25} onChange={setCustomH} className="editor-size-input" aria-label={t('editor.height')} />
|
||||||
<button className="btn btn-sm" onClick={() => newPuzzle(customW, customH)}><IconPlus size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('common.create')}</button>
|
<button className="btn btn-sm" onClick={() => newPuzzle(customW, customH)}><IconPlus size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('common.create')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,8 +666,8 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<span className={`cc-status cc-status-${p.status}`}>{p.status}</span>
|
<span className={`cc-status cc-status-${p.status}`}>{p.status}</span>
|
||||||
{p.status === 'published' && (
|
{p.status === 'published' && (
|
||||||
<Tooltip text={t('constructorAnalytics.title')}>
|
<Tooltip text={t('constructorAnalytics.title')}>
|
||||||
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); toggleAnalytics(p.id) }} style={{ padding: '0.125rem 0.25rem' }}>
|
<button className="btn btn-sm btn-ghost" aria-label={t('constructorAnalytics.title')} onClick={(e) => { e.stopPropagation(); toggleAnalytics(p.id) }} style={{ padding: '0.125rem 0.25rem' }}>
|
||||||
<IconChartAreaFilled size={11} />
|
<IconChartAreaFilled size={11} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -620,7 +679,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<button className="btn btn-sm btn-ghost" style={{ fontSize: '0.5rem', padding: '0.125rem 0.375rem' }} onClick={() => setDeleteConfirmId(null)}>{t('common.cancel')}</button>
|
<button className="btn btn-sm btn-ghost" style={{ fontSize: '0.5rem', padding: '0.125rem 0.375rem' }} onClick={() => setDeleteConfirmId(null)}>{t('common.cancel')}</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button className="cc-delete" onClick={() => confirmDelete(p.id)}><IconTrashFilled size={11} /></button>
|
<button className="cc-delete" aria-label={t('editor.delete')} onClick={() => confirmDelete(p.id)}><IconTrashFilled size={11} aria-hidden="true" /></button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -711,14 +770,14 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="editor-header-left">
|
<div className="editor-header-left">
|
||||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setView('list')} aria-label={t('common.back')}><IconArrowBack size={16} aria-hidden="true" /></button>
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setView('list')} aria-label={t('common.back')}><IconArrowBack size={16} aria-hidden="true" /></button>
|
||||||
<button type="button" className="header-brand" onClick={onClose}>{t('app.name')}</button>
|
<button type="button" className="header-brand" onClick={onClose}>{t('app.name')}</button>
|
||||||
<input className="editor-title-input" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('editor.puzzleTitle')} />
|
<input className="editor-title-input" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('editor.puzzleTitle')} aria-label={t('editor.puzzleTitle')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-header-actions">
|
<div className="editor-header-actions">
|
||||||
<div className="editor-mode-toggle">
|
<div className="editor-mode-toggle">
|
||||||
<button className={`btn btn-sm ${mode === 'design' ? 'btn-primary' : ''}`} onClick={() => { setMode('design'); setHighlightedCells(new Set()) }}><IconPointerFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.design')}</button>
|
<button className={`btn btn-sm ${mode === 'design' ? 'btn-primary' : ''}`} aria-pressed={mode === 'design'} onClick={() => { setMode('design'); setHighlightedCells(new Set()) }}><IconPointerFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.design')}</button>
|
||||||
<button className={`btn btn-sm ${mode === 'fill' ? 'btn-primary' : ''}`} onClick={() => { setMode('fill'); setHighlightedCells(new Set()) }}><IconPencilFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.fill')}</button>
|
<button className={`btn btn-sm ${mode === 'fill' ? 'btn-primary' : ''}`} aria-pressed={mode === 'fill'} onClick={() => { setMode('fill'); setHighlightedCells(new Set()) }}><IconPencilFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.fill')}</button>
|
||||||
<button className={`btn btn-sm ${mode === 'clues' ? 'btn-primary' : ''}`} onClick={generateClues}><IconListFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.clues')}</button>
|
<button className={`btn btn-sm ${mode === 'clues' ? 'btn-primary' : ''}`} aria-pressed={mode === 'clues'} onClick={generateClues}><IconListFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.clues')}</button>
|
||||||
{clues.length > 0 && <button className={`btn btn-sm ${mode === 'test' ? 'btn-primary' : ''}`} onClick={isTest ? exitTest : startTest}><IconPlayerPlayFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} />{isTest ? t('editor.exitTest') : t('common.test')}</button>}
|
{clues.length > 0 && <button className={`btn btn-sm ${mode === 'test' ? 'btn-primary' : ''}`} aria-pressed={mode === 'test'} onClick={isTest ? exitTest : startTest}><IconPlayerPlayFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} />{isTest ? t('editor.exitTest') : t('common.test')}</button>}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm btn-primary" onClick={autofill} disabled={filling}><IconSparklesFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} />{filling ? t('editor.filling') : t('editor.autoFill')}</button>
|
<button className="btn btn-sm btn-primary" onClick={autofill} disabled={filling}><IconSparklesFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} />{filling ? t('editor.filling') : t('editor.autoFill')}</button>
|
||||||
<Tooltip text={t('editor.undoCtrlZ')}><button className="btn btn-sm" onClick={undo} disabled={undoStack.length === 0}><IconArrowBack size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.undo')}</button></Tooltip>
|
<Tooltip text={t('editor.undoCtrlZ')}><button className="btn btn-sm" onClick={undo} disabled={undoStack.length === 0}><IconArrowBack size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.undo')}</button></Tooltip>
|
||||||
@@ -736,12 +795,12 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
]} />
|
]} />
|
||||||
{clues.length > 0 && <button className="btn btn-sm" onClick={tryPublish} disabled={saving}><IconWorldFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.publish')}</button>}
|
{clues.length > 0 && <button className="btn btn-sm" onClick={tryPublish} disabled={saving}><IconWorldFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.publish')}</button>}
|
||||||
{lastSaved && <span className="editor-autosave-indicator">saved {lastSaved}</span>}
|
{lastSaved && <span className="editor-autosave-indicator">saved {lastSaved}</span>}
|
||||||
<button className="btn btn-sm btn-ghost" onClick={onClose}><IconX size={16} /></button>
|
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(warnings.length > 0 || twoLetterWarnings.length > 0) && (
|
{(warnings.length > 0 || twoLetterWarnings.length > 0) && (
|
||||||
<div className="editor-warnings" style={{ justifyContent: 'center' }}>
|
<div className="editor-warnings" role="status" aria-live="polite" style={{ justifyContent: 'center' }}>
|
||||||
<IconAlertTriangleFilled size={14} />
|
<IconAlertTriangleFilled size={14} />
|
||||||
{warnings.map((w, i) => <span key={`w${i}`}>{w}</span>)}
|
{warnings.map((w, i) => <span key={`w${i}`}>{w}</span>)}
|
||||||
{twoLetterWarnings.length > 0 && <span>{twoLetterWarnings.length} two-letter slot{twoLetterWarnings.length > 1 ? 's' : ''} (min 3 letters)</span>}
|
{twoLetterWarnings.length > 0 && <span>{twoLetterWarnings.length} two-letter slot{twoLetterWarnings.length > 1 ? 's' : ''} (min 3 letters)</span>}
|
||||||
@@ -750,7 +809,8 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
<div className="editor-workspace">
|
<div className="editor-workspace">
|
||||||
<div className="editor-grid-area">
|
<div className="editor-grid-area">
|
||||||
<svg viewBox={`0 0 ${width * cellSize} ${height * cellSize}`} style={{ maxWidth: width * cellSize, width: '100%' }}>
|
<div className="editor-grid-wrap">
|
||||||
|
<svg viewBox={`0 0 ${width * cellSize} ${height * cellSize}`} aria-hidden="true" style={{ maxWidth: width * cellSize, width: '100%' }}>
|
||||||
{cells.map((row, r) => row.map((cell, c) => {
|
{cells.map((row, r) => row.map((cell, c) => {
|
||||||
const isSel = displaySelected?.row === r && displaySelected?.col === c
|
const isSel = displaySelected?.row === r && displaySelected?.col === c
|
||||||
const isHighlighted = highlightedCells.has(`${r},${c}`)
|
const isHighlighted = highlightedCells.has(`${r},${c}`)
|
||||||
@@ -766,6 +826,21 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
)
|
)
|
||||||
}))}
|
}))}
|
||||||
</svg>
|
</svg>
|
||||||
|
<div className="editor-sr-grid" role="grid" aria-label={t('editor.title')}
|
||||||
|
aria-rowcount={height} aria-colcount={width}
|
||||||
|
style={{ gridTemplateColumns: `repeat(${width}, 1fr)` }}
|
||||||
|
onKeyDown={onSrGridKeyDown}>
|
||||||
|
{cells.map((row, r) => row.map((_, c) => (
|
||||||
|
<button key={`sr-${r}-${c}`} ref={el => { srCellRefs.current[r * width + c] = el }}
|
||||||
|
type="button" className="editor-sr-cell" role="gridcell"
|
||||||
|
aria-rowindex={r + 1} aria-colindex={c + 1}
|
||||||
|
tabIndex={r * width + c === focusableIdx ? 0 : -1}
|
||||||
|
aria-label={srGridLabel(r, c)}
|
||||||
|
onFocus={() => { if (displaySelected?.row !== r || displaySelected?.col !== c) selectCellAt(r, c) }}
|
||||||
|
onClick={() => onCellClick(r, c)} />
|
||||||
|
)))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="editor-grid-hint">
|
<div className="editor-grid-hint">
|
||||||
{mode === 'design' ? t('editor.designHint')
|
{mode === 'design' ? t('editor.designHint')
|
||||||
: mode === 'fill' ? t('editor.fillHint', { dir: editorDirection })
|
: mode === 'fill' ? t('editor.fillHint', { dir: editorDirection })
|
||||||
@@ -773,7 +848,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
: t('editor.clueHint')}
|
: t('editor.clueHint')}
|
||||||
</div>
|
</div>
|
||||||
{mode === 'design' && (
|
{mode === 'design' && (
|
||||||
<Select value={symmetry} onChange={v => { const s = String(v); if (s === 'rotational180' || s === 'mirror' || s === 'none') setSymmetry(s) }} className="btn btn-sm" style={{ marginTop: 8 }} options={[
|
<Select value={symmetry} onChange={v => { const s = String(v); if (s === 'rotational180' || s === 'mirror' || s === 'none') setSymmetry(s) }} aria-label={t('editor.symmetry')} className="btn btn-sm" style={{ marginTop: 8 }} options={[
|
||||||
{ value: 'rotational180', label: t('editor.rotationalSymmetry') },
|
{ value: 'rotational180', label: t('editor.rotationalSymmetry') },
|
||||||
{ value: 'mirror', label: t('editor.mirrorSymmetry') },
|
{ value: 'mirror', label: t('editor.mirrorSymmetry') },
|
||||||
{ value: 'none', label: t('editor.noSymmetry') },
|
{ value: 'none', label: t('editor.noSymmetry') },
|
||||||
@@ -826,7 +901,9 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
{showPublishConfirm && (
|
{showPublishConfirm && (
|
||||||
<div className="settings-overlay" onClick={() => setShowPublishConfirm(false)}>
|
<div className="settings-overlay" onClick={() => setShowPublishConfirm(false)}>
|
||||||
<div className="editor-publish-dialog" onClick={e => e.stopPropagation()}>
|
<div className="editor-publish-dialog" role="dialog" aria-modal="true" aria-label={t('editor.publishTitle')}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onKeyDown={e => { if (e.key === 'Escape') setShowPublishConfirm(false) }}>
|
||||||
<h3>{t('editor.publishTitle')}</h3>
|
<h3>{t('editor.publishTitle')}</h3>
|
||||||
<p>{t('editor.publishDesc')}</p>
|
<p>{t('editor.publishDesc')}</p>
|
||||||
<p>{t('editor.publishNote')}</p>
|
<p>{t('editor.publishNote')}</p>
|
||||||
@@ -835,7 +912,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{t('editor.isThemed')}
|
{t('editor.isThemed')}
|
||||||
</label>
|
</label>
|
||||||
{isThemed && (
|
{isThemed && (
|
||||||
<input type="text" placeholder={t('editor.themePlaceholder')}
|
<input type="text" placeholder={t('editor.themePlaceholder')} aria-label={t('editor.themePlaceholder')}
|
||||||
value={themeName} onChange={e => setThemeName(e.target.value)}
|
value={themeName} onChange={e => setThemeName(e.target.value)}
|
||||||
style={{ width: '100%', marginTop: 6, marginBottom: 6, padding: '0.375rem 0.5rem',
|
style={{ width: '100%', marginTop: 6, marginBottom: 6, padding: '0.375rem 0.5rem',
|
||||||
fontFamily: 'var(--font-body)', fontSize: '0.75rem',
|
fontFamily: 'var(--font-body)', fontSize: '0.75rem',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Grid from './Grid'
|
|||||||
import CluePanel from './CluePanel'
|
import CluePanel from './CluePanel'
|
||||||
import ActiveClue from './ActiveClue'
|
import ActiveClue from './ActiveClue'
|
||||||
import { useKeyboard } from '../hooks/useKeyboard'
|
import { useKeyboard } from '../hooks/useKeyboard'
|
||||||
|
import { useNarrator } from '../hooks/useNarrator'
|
||||||
|
import { useSounds } from '../hooks/useSounds'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import type { ClientPuzzle } from '../types'
|
import type { ClientPuzzle } from '../types'
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
|||||||
const paused = useStore(s => s.paused)
|
const paused = useStore(s => s.paused)
|
||||||
const tick = useStore(s => s.tick)
|
const tick = useStore(s => s.tick)
|
||||||
useKeyboard()
|
useKeyboard()
|
||||||
|
useNarrator()
|
||||||
|
useSounds()
|
||||||
|
|
||||||
// timer
|
// timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,8 +106,8 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
|||||||
}
|
}
|
||||||
}, [solved, elapsed])
|
}, [solved, elapsed])
|
||||||
|
|
||||||
if (loading) return <div className="embed-loading"><div className="spinner" /></div>
|
if (loading) return <div className="embed-loading" role="status"><div className="spinner" /></div>
|
||||||
if (error) return <div className="embed-error">{error}</div>
|
if (error) return <div className="embed-error" role="alert">{error}</div>
|
||||||
if (!puzzle) return null
|
if (!puzzle) return null
|
||||||
|
|
||||||
const mins = Math.floor(elapsed / 60)
|
const mins = Math.floor(elapsed / 60)
|
||||||
@@ -111,7 +115,7 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="embed-player">
|
<div className="embed-player">
|
||||||
{solved && <div className="embed-solved">{t('embed.solved')}</div>}
|
{solved && <div className="embed-solved" role="status">{t('embed.solved')}</div>}
|
||||||
<div className="embed-header">
|
<div className="embed-header">
|
||||||
<span className="embed-brand">{t('embed.brand')}</span>
|
<span className="embed-brand">{t('embed.brand')}</span>
|
||||||
{showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>}
|
{showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>}
|
||||||
|
|||||||
@@ -14,11 +14,21 @@ function getCellSize(gridSize: number): number {
|
|||||||
return 36
|
return 36
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstWhiteIdx(cells: string[][], width: number): number {
|
||||||
|
for (let r = 0; r < cells.length; r++) {
|
||||||
|
for (let c = 0; c < cells[r].length; c++) {
|
||||||
|
if (cells[r][c] !== 'black') return r * width + c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
export default function Grid() {
|
export default function Grid() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const cellRefs = useRef<(HTMLButtonElement | null)[]>([])
|
const cellRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
const typingRef = useRef<HTMLInputElement>(null)
|
||||||
const puzzle = useStore((s) => s.puzzle)
|
const puzzle = useStore((s) => s.puzzle)
|
||||||
const entries = useStore((s) => s.entries)
|
const entries = useStore((s) => s.entries)
|
||||||
const cellStatus = useStore((s) => s.cellStatus)
|
const cellStatus = useStore((s) => s.cellStatus)
|
||||||
@@ -35,11 +45,28 @@ export default function Grid() {
|
|||||||
const height = puzzle?.height ?? 0
|
const height = puzzle?.height ?? 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCell || !wrapperRef.current?.contains(document.activeElement)) return
|
if (!selectedCell || !wrapperRef.current) return
|
||||||
|
if (!wrapperRef.current.contains(document.activeElement)) return
|
||||||
const idx = selectedCell.row * width + selectedCell.col
|
const idx = selectedCell.row * width + selectedCell.col
|
||||||
cellRefs.current[idx]?.focus()
|
cellRefs.current[idx]?.focus()
|
||||||
}, [selectedCell, width])
|
}, [selectedCell, width])
|
||||||
|
|
||||||
|
// land focus on the starting cell when a puzzle loads and nothing else has
|
||||||
|
// it, so typing works without clicking into the grid first
|
||||||
|
useEffect(() => {
|
||||||
|
if (!puzzle) return
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (active !== document.body && active !== null) return
|
||||||
|
const st = useStore.getState()
|
||||||
|
const idx = st.selectedCell
|
||||||
|
? st.selectedCell.row * width + st.selectedCell.col
|
||||||
|
: firstWhiteIdx(puzzle.cells, width)
|
||||||
|
cellRefs.current[idx]?.focus({ preventScroll: true })
|
||||||
|
}, 0)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [puzzle, width])
|
||||||
|
|
||||||
if (!puzzle) return null
|
if (!puzzle) return null
|
||||||
|
|
||||||
const CELL = getCellSize(Math.max(width, height))
|
const CELL = getCellSize(Math.max(width, height))
|
||||||
@@ -53,6 +80,79 @@ export default function Grid() {
|
|||||||
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
|
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build each cell's accessible name so a screen reader in browse mode reads
|
||||||
|
// the word it belongs to (and the clue on the starting cell), not just empty.
|
||||||
|
const buildCellLabel = (r: number, c: number): string => {
|
||||||
|
const st = useStore.getState()
|
||||||
|
const puzzle = st.puzzle
|
||||||
|
if (!puzzle) return t('grid.cellEmpty')
|
||||||
|
if (puzzle.cells[r][c] === 'black') return t('grid.blackCell')
|
||||||
|
|
||||||
|
const num = puzzle.cell_numbers[`${r},${c}`]
|
||||||
|
const letter = st.entries[r]?.[c] || ''
|
||||||
|
const pencilLetter = !letter && st.pencilEntries[r]?.[c] ? st.pencilEntries[r][c] : ''
|
||||||
|
const status: CellStatus = st.cellStatus[r]?.[c] || 'default'
|
||||||
|
|
||||||
|
let label = t('grid.cellLabel', { row: r + 1, col: c + 1 }) +
|
||||||
|
(num ? t('grid.cellNumber', { num }) : '') +
|
||||||
|
(letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) +
|
||||||
|
(status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '')
|
||||||
|
|
||||||
|
const spansHere = st.wordSpans.filter((s) => s.cells.some(([sr, sc]) => sr === r && sc === c))
|
||||||
|
for (const s of spansHere) {
|
||||||
|
const dir = t(s.direction === 'across' ? 'game.across' : 'game.down')
|
||||||
|
const isStart = s.cells[0][0] === r && s.cells[0][1] === c
|
||||||
|
if (isStart) {
|
||||||
|
const clues = s.direction === 'across' ? puzzle.clues_across : puzzle.clues_down
|
||||||
|
const clue = clues.find((c) => c.number === s.number)
|
||||||
|
label += clue
|
||||||
|
? t('grid.cellClue', { number: s.number, dir, clue: clue.text })
|
||||||
|
: t('grid.cellWord', { number: s.number, dir })
|
||||||
|
} else {
|
||||||
|
label += t('grid.cellWord', { number: s.number, dir })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compact form for braille displays: the spoken label above is verbose, and
|
||||||
|
// braille users read faster with short symbolic codes.
|
||||||
|
const buildCellBrailleLabel = (r: number, c: number): string => {
|
||||||
|
const st = useStore.getState()
|
||||||
|
const puzzle = st.puzzle
|
||||||
|
if (!puzzle) return ''
|
||||||
|
if (puzzle.cells[r][c] === 'black') return 'black'
|
||||||
|
const num = puzzle.cell_numbers[`${r},${c}`]
|
||||||
|
const letter = st.entries[r]?.[c] || ''
|
||||||
|
const status: CellStatus = st.cellStatus[r]?.[c] || 'default'
|
||||||
|
let out = `r${r + 1}c${c + 1}`
|
||||||
|
if (num) out += ` n${num}`
|
||||||
|
out += letter ? ` ${letter}` : ' blank'
|
||||||
|
if (status === 'correct') out += ' ok'
|
||||||
|
else if (status === 'incorrect') out += ' wrong'
|
||||||
|
else if (status === 'revealed') out += ' revealed'
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusTypingField = (r: number, c: number) => {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||||
|
const field = typingRef.current
|
||||||
|
if (field) {
|
||||||
|
field.setAttribute('aria-label', buildCellLabel(r, c))
|
||||||
|
field.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// desktop: park focus on the matching grid cell so keystrokes reach it
|
||||||
|
cellRefs.current[r * width + c]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one cell is a Tab stop so the grid can receive keyboard focus even
|
||||||
|
// before a cell has been selected.
|
||||||
|
const focusableIdx = selectedCell
|
||||||
|
? selectedCell.row * width + selectedCell.col
|
||||||
|
: firstWhiteIdx(puzzle.cells, width)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
@@ -100,15 +200,25 @@ export default function Grid() {
|
|||||||
key={`${r},${c}`}
|
key={`${r},${c}`}
|
||||||
id={`cell-${r}-${c}`}
|
id={`cell-${r}-${c}`}
|
||||||
data-cell={`${r},${c}`}
|
data-cell={`${r},${c}`}
|
||||||
onClick={() => !isBlack && selectCell(r, c)}
|
onClick={() => {
|
||||||
|
if (isBlack) {
|
||||||
|
// keep keyboard focus inside the grid after tapping a black square
|
||||||
|
if (!window.matchMedia('(pointer: coarse)').matches) {
|
||||||
|
cellRefs.current[focusableIdx]?.focus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectCell(r, c)
|
||||||
|
focusTypingField(r, c)
|
||||||
|
}}
|
||||||
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
x={x} y={y}
|
x={x} y={y}
|
||||||
width={CELL} height={CELL}
|
width={CELL} height={CELL}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
stroke="var(--cell-border)"
|
stroke={isSelected ? 'var(--accent)' : 'var(--cell-border)'}
|
||||||
strokeWidth={0.5}
|
strokeWidth={isSelected ? 2 : 0.5}
|
||||||
/>
|
/>
|
||||||
{!isBlack && num && (
|
{!isBlack && num && (
|
||||||
<text
|
<text
|
||||||
@@ -193,36 +303,65 @@ export default function Grid() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/* screen reader layer: one focusable cell per grid cell, invisible to the eye */}
|
{/* screen reader layer: a real ARIA grid mirroring the visual one */}
|
||||||
<div
|
<div
|
||||||
className="grid-sr-grid"
|
className="grid-sr-grid"
|
||||||
|
role="grid"
|
||||||
|
aria-label={t('grid.ariaLabel', { width, height })}
|
||||||
|
aria-rowcount={height}
|
||||||
|
aria-colcount={width}
|
||||||
style={{ gridTemplateColumns: `repeat(${width}, 1fr)`, aspectRatio: `${width} / ${height}` }}
|
style={{ gridTemplateColumns: `repeat(${width}, 1fr)`, aspectRatio: `${width} / ${height}` }}
|
||||||
>
|
>
|
||||||
{Array.from({ length: height }, (_, r) =>
|
{Array.from({ length: height }, (_, r) => (
|
||||||
Array.from({ length: width }, (_, c) => {
|
<div key={`sr-row-${r}`} role="row" aria-rowindex={r + 1} className="grid-sr-row" style={{ display: 'contents' }}>
|
||||||
|
{Array.from({ length: width }, (_, c) => {
|
||||||
const isBlack = puzzle.cells[r][c] === 'black'
|
const isBlack = puzzle.cells[r][c] === 'black'
|
||||||
const isSelected = selectedCell?.row === r && selectedCell?.col === c
|
const isSelected = selectedCell?.row === r && selectedCell?.col === c
|
||||||
const num = puzzle.cell_numbers[`${r},${c}`]
|
const label = buildCellLabel(r, c)
|
||||||
const letter = entries[r]?.[c] || ''
|
const braille = buildCellBrailleLabel(r, c)
|
||||||
const pencilLetter = !letter && pencilEntries[r]?.[c] ? pencilEntries[r][c] : ''
|
|
||||||
const status: CellStatus = cellStatus[r]?.[c] || 'default'
|
|
||||||
const label = isBlack ? t('grid.blackCell') :
|
|
||||||
t('grid.cellLabel', { row: r + 1, col: c + 1 }) + (num ? t('grid.cellNumber', { num }) : '') + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) +
|
|
||||||
(status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '')
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`sr-${r}-${c}`}
|
key={`sr-${r}-${c}`}
|
||||||
ref={el => { cellRefs.current[r * width + c] = el }}
|
ref={el => { cellRefs.current[r * width + c] = el }}
|
||||||
type="button"
|
type="button"
|
||||||
className="grid-sr-cell"
|
className="grid-sr-cell"
|
||||||
tabIndex={isSelected ? 0 : -1}
|
role="gridcell"
|
||||||
|
aria-rowindex={r + 1}
|
||||||
|
aria-colindex={c + 1}
|
||||||
|
tabIndex={r * width + c === focusableIdx ? 0 : -1}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-braillelabel={braille}
|
||||||
|
onFocus={() => { if (!isSelected && !isBlack) selectCell(r, c) }}
|
||||||
onClick={() => !isBlack && selectCell(r, c)}
|
onClick={() => !isBlack && selectCell(r, c)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}),
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={typingRef}
|
||||||
|
className="grid-type-input"
|
||||||
|
type="text"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="text"
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value
|
||||||
|
e.target.value = ''
|
||||||
|
for (const ch of value.toLowerCase()) {
|
||||||
|
if (ch >= 'a' && ch <= 'z') useStore.getState().typeLetter(ch)
|
||||||
|
else if (ch === ' ') useStore.getState().toggleDirection()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Backspace' && !e.currentTarget.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
useStore.getState().deleteLetter()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<CellFlip svgRef={svgRef} />
|
<CellFlip svgRef={svgRef} />
|
||||||
<InkOverlay svgRef={svgRef} />
|
<InkOverlay svgRef={svgRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { IconFlameFilled, IconShare, IconSettings, IconPlus, IconTrophyFilled,
|
import { IconFlameFilled, IconShare, IconSettings, IconPlus, IconTrophyFilled,
|
||||||
IconMessageFilled, IconPencilFilled, IconDotsFilled, IconKeyFilled, IconWorldFilled,
|
IconMessageFilled, IconPencilFilled, IconDotsFilled, IconKeyFilled, IconWorldFilled,
|
||||||
IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled, IconDeviceSpeakerFilled,
|
IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled,
|
||||||
IconKeyboardFilled, IconX, IconClockFilled, IconArchiveFilled, IconSnowflake,
|
IconKeyboardFilled, IconX, IconClockFilled, IconArchiveFilled, IconSnowflake,
|
||||||
IconTargetArrow } from '@tabler/icons-react'
|
IconTargetArrow } from '@tabler/icons-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
@@ -29,7 +29,6 @@ export default function Header() {
|
|||||||
const shortId = useStore((s) => s.shortId)
|
const shortId = useStore((s) => s.shortId)
|
||||||
const streak = useStore((s) => s.streak)
|
const streak = useStore((s) => s.streak)
|
||||||
const streakFreezes = useStore((s) => s.streakFreezes)
|
const streakFreezes = useStore((s) => s.streakFreezes)
|
||||||
const narratorEnabled = useStore((s) => s.narratorEnabled)
|
|
||||||
const mpRoomCode = useStore((s) => s.mpRoomCode)
|
const mpRoomCode = useStore((s) => s.mpRoomCode)
|
||||||
const mpPlayers = useStore((s) => s.mpPlayers)
|
const mpPlayers = useStore((s) => s.mpPlayers)
|
||||||
const mpConnected = useStore((s) => s.mpConnected)
|
const mpConnected = useStore((s) => s.mpConnected)
|
||||||
@@ -135,13 +134,6 @@ export default function Header() {
|
|||||||
<span className="header-freeze"><IconSnowflake size={12} aria-hidden="true" /> {streakFreezes}</span>
|
<span className="header-freeze"><IconSnowflake size={12} aria-hidden="true" /> {streakFreezes}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{narratorEnabled && (
|
|
||||||
<Tooltip text={t('header.narratorActive')}>
|
|
||||||
<span className="header-narrator-tag" aria-label={t('header.narratorActive')}>
|
|
||||||
<IconDeviceSpeakerFilled size={12} aria-hidden="true" /> {t('header.narrator')}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{mpConnected && mpRoomCode && (
|
{mpConnected && mpRoomCode && (
|
||||||
<button type="button" className="header-room-tag" onClick={() => setShowMultiplayer(true)}>
|
<button type="button" className="header-room-tag" onClick={() => setShowMultiplayer(true)}>
|
||||||
<span className="mp-status-dot mp-connected" /> {mpRoomCode} - {mpPlayers.length}p
|
<span className="mp-status-dot mp-connected" /> {mpRoomCode} - {mpPlayers.length}p
|
||||||
@@ -344,12 +336,8 @@ export default function Header() {
|
|||||||
<span className="sk-desc">{t('shortcuts.arrows')}</span>
|
<span className="sk-desc">{t('shortcuts.arrows')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sk-row">
|
<div className="sk-row">
|
||||||
<div className="sk-keys"><kbd>Tab</kbd></div>
|
<div className="sk-keys"><kbd>Home</kbd> / <kbd>End</kbd></div>
|
||||||
<span className="sk-desc">{t('shortcuts.tabNext')}</span>
|
<span className="sk-desc">{t('shortcuts.homeEnd')}</span>
|
||||||
</div>
|
|
||||||
<div className="sk-row">
|
|
||||||
<div className="sk-keys"><kbd>Shift</kbd> + <kbd>Tab</kbd></div>
|
|
||||||
<span className="sk-desc">{t('shortcuts.tabPrev')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sk-row">
|
<div className="sk-row">
|
||||||
<div className="sk-keys"><kbd>Space</kbd></div>
|
<div className="sk-keys"><kbd>Space</kbd></div>
|
||||||
@@ -379,19 +367,11 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sk-section">
|
<div className="sk-section">
|
||||||
<h4 className="sk-section-title">{t('shortcuts.narrator')}</h4>
|
<h4 className="sk-section-title">{t('shortcuts.reading')}</h4>
|
||||||
<div className="sk-grid">
|
<div className="sk-grid">
|
||||||
<div className="sk-row">
|
<div className="sk-row">
|
||||||
<div className="sk-keys"><kbd>?</kbd></div>
|
<div className="sk-keys"><kbd>Alt</kbd> + <kbd>W</kbd></div>
|
||||||
<span className="sk-desc">{t('shortcuts.question')}</span>
|
<span className="sk-desc">{t('shortcuts.readWord')}</span>
|
||||||
</div>
|
|
||||||
<div className="sk-row">
|
|
||||||
<div className="sk-keys"><kbd>Ctrl</kbd> + <kbd>H</kbd></div>
|
|
||||||
<span className="sk-desc">{t('shortcuts.ctrlH')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="sk-row">
|
|
||||||
<div className="sk-keys"><kbd>F1</kbd></div>
|
|
||||||
<span className="sk-desc">{t('shortcuts.f1')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function InfoBar() {
|
|||||||
return (
|
return (
|
||||||
<div className="info-bar">
|
<div className="info-bar">
|
||||||
<span className={`timer ${timeWarning ? 'timer-warning' : ''}`}>
|
<span className={`timer ${timeWarning ? 'timer-warning' : ''}`}>
|
||||||
|
{timeWarning && <span className="visually-hidden">Time is running low</span>}
|
||||||
{isSpeedRun && <IconBoltFilled size={11} style={{ color: '#fbbf24', marginRight: 2 }} aria-hidden="true" />}
|
{isSpeedRun && <IconBoltFilled size={11} style={{ color: '#fbbf24', marginRight: 2 }} aria-hidden="true" />}
|
||||||
{isMarathon && <IconPlayerTrackNextFilled size={11} style={{ color: '#60a5fa', marginRight: 2 }} aria-hidden="true" />}
|
{isMarathon && <IconPlayerTrackNextFilled size={11} style={{ color: '#60a5fa', marginRight: 2 }} aria-hidden="true" />}
|
||||||
<IconClockFilled size={12} style={{ opacity: 0.4, marginRight: 3 }} aria-hidden="true" />
|
<IconClockFilled size={12} style={{ opacity: 0.4, marginRight: 3 }} aria-hidden="true" />
|
||||||
|
|||||||
@@ -178,20 +178,23 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="mp-mode-picker mp-mode-picker-3">
|
<div className="mp-mode-picker mp-mode-picker-3">
|
||||||
<button
|
<button
|
||||||
className={`mp-mode-btn ${mode === 'coop' ? 'mp-mode-active' : ''}`}
|
className={`mp-mode-btn ${mode === 'coop' ? 'mp-mode-active' : ''}`}
|
||||||
|
aria-pressed={mode === 'coop'}
|
||||||
onClick={() => setMode('coop')}
|
onClick={() => setMode('coop')}
|
||||||
>
|
>
|
||||||
<IconHeartFilled size={16} />
|
<IconHeartFilled size={16} aria-hidden="true" />
|
||||||
<span>{t('multiplayer.coop')}</span>
|
<span>{t('multiplayer.coop')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mp-mode-btn ${mode === 'competitive' ? 'mp-mode-active' : ''}`}
|
className={`mp-mode-btn ${mode === 'competitive' ? 'mp-mode-active' : ''}`}
|
||||||
|
aria-pressed={mode === 'competitive'}
|
||||||
onClick={() => setMode('competitive')}
|
onClick={() => setMode('competitive')}
|
||||||
>
|
>
|
||||||
<IconBoltFilled size={16} />
|
<IconBoltFilled size={16} aria-hidden="true" />
|
||||||
<span>{t('multiplayer.race')}</span>
|
<span>{t('multiplayer.race')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mp-mode-btn ${mode === 'party' ? 'mp-mode-active' : ''}`}
|
className={`mp-mode-btn ${mode === 'party' ? 'mp-mode-active' : ''}`}
|
||||||
|
aria-pressed={mode === 'party'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const wasParty = mode === 'party'
|
const wasParty = mode === 'party'
|
||||||
setMode('party')
|
setMode('party')
|
||||||
@@ -199,6 +202,7 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
|||||||
// already selected, do nothing
|
// already selected, do nothing
|
||||||
} else {
|
} else {
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
|
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
|
||||||
document.body.appendChild(canvas)
|
document.body.appendChild(canvas)
|
||||||
@@ -213,15 +217,17 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
|||||||
},
|
},
|
||||||
}).then(() => canvas.remove())
|
}).then(() => canvas.remove())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconConfettiFilled size={16} />
|
<IconConfettiFilled size={16} aria-hidden="true" />
|
||||||
<span>{t('multiplayer.partyLabel')}</span>
|
<span>{t('multiplayer.partyLabel')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mode === 'party' && (
|
{mode === 'party' && (
|
||||||
<motion.button type="button" key="party-sub" ref={partyBtnRef} className="mp-party-sub-selected"
|
<motion.button type="button" key="party-sub" ref={partyBtnRef} className="mp-party-sub-selected"
|
||||||
|
aria-haspopup="listbox" aria-expanded={partyDropOpen}
|
||||||
onClick={() => setPartyDropOpen(!partyDropOpen)}
|
onClick={() => setPartyDropOpen(!partyDropOpen)}
|
||||||
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
|
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
|
||||||
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
|
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
|
||||||
@@ -285,8 +291,9 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handleJoin()}
|
onClick={() => handleJoin()}
|
||||||
disabled={busy || joinCode.length < 4}
|
disabled={busy || joinCode.length < 4}
|
||||||
|
aria-label={t('multiplayer.joinRoom')}
|
||||||
>
|
>
|
||||||
<IconArrowBigRightFilled size={14} />
|
<IconArrowBigRightFilled size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default function OnboardingModal() {
|
|||||||
type="text"
|
type="text"
|
||||||
className="onboard-username"
|
className="onboard-username"
|
||||||
placeholder={t('common.anonymous')}
|
placeholder={t('common.anonymous')}
|
||||||
|
aria-label={t('onboarding.chooseName')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={e => setUsername(e.target.value)}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
@@ -96,6 +97,7 @@ export default function OnboardingModal() {
|
|||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`}
|
className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`}
|
||||||
|
aria-pressed={lifetime === opt.value}
|
||||||
onClick={() => setLifetime(opt.value)}
|
onClick={() => setLifetime(opt.value)}
|
||||||
>
|
>
|
||||||
{t(opt.key)}
|
{t(opt.key)}
|
||||||
@@ -120,7 +122,7 @@ export default function OnboardingModal() {
|
|||||||
{registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')}
|
{registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{passkeyDone && <span className="onboard-success" style={{ display: 'block', marginBottom: 8 }}>{t('onboarding.passkeySaved')}</span>}
|
{passkeyDone && <span className="onboard-success" role="status" style={{ display: 'block', marginBottom: 8 }}>{t('onboarding.passkeySaved')}</span>}
|
||||||
|
|
||||||
{error && <div className="onboard-error" role="alert">{error}</div>}
|
{error && <div className="onboard-error" role="alert">{error}</div>}
|
||||||
|
|
||||||
@@ -154,7 +156,7 @@ export default function OnboardingModal() {
|
|||||||
<IconArrowRight size={14} />
|
<IconArrowRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{importResult && <p className="settings-hint" style={{ marginTop: 8 }}>{importResult}</p>}
|
{importResult && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{importResult}</p>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function Scoreboard({ scores, myId }: { scores: { id: string; name: string; colo
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const sorted = [...scores].sort((a, b) => b.score - a.score)
|
const sorted = [...scores].sort((a, b) => b.score - a.score)
|
||||||
return (
|
return (
|
||||||
<div className="party-scoreboard">
|
<div className="party-scoreboard" aria-live="polite">
|
||||||
<div className="party-scoreboard-title">{t('party.scores')}</div>
|
<div className="party-scoreboard-title">{t('party.scores')}</div>
|
||||||
{sorted.map((p, i) => (
|
{sorted.map((p, i) => (
|
||||||
<div key={p.id} className={`party-score-row ${p.id === myId ? 'party-score-you' : ''}`}>
|
<div key={p.id} className={`party-score-row ${p.id === myId ? 'party-score-you' : ''}`}>
|
||||||
@@ -124,6 +124,7 @@ function SubmitPhase({ prompt, placeholder, maxLen }: { prompt: string; placehol
|
|||||||
) : (
|
) : (
|
||||||
<motion.div key="submitted"
|
<motion.div key="submitted"
|
||||||
className="party-submitted"
|
className="party-submitted"
|
||||||
|
role="status"
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
@@ -180,7 +181,7 @@ function VotePhase({ prompt }: { prompt: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{myVote && (
|
{myVote && (
|
||||||
<motion.div className="party-voted"
|
<motion.div className="party-voted" role="status"
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
{t('party.voteLocked')}
|
{t('party.voteLocked')}
|
||||||
@@ -339,7 +340,7 @@ function SpeedRacePhase() {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{correctPlayers.length > 0 && (
|
{correctPlayers.length > 0 && (
|
||||||
<motion.div key="feed" className="party-speed-feed"
|
<motion.div key="feed" className="party-speed-feed" aria-live="polite"
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -403,7 +404,7 @@ function RouletteChoosePhase() {
|
|||||||
</div>
|
</div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{myPick && (
|
{myPick && (
|
||||||
<motion.div className="party-voted"
|
<motion.div className="party-voted" role="status"
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
{t('party.lockedIn')}
|
{t('party.lockedIn')}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
|
|||||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
<motion.div className="settings-panel" onClick={e => e.stopPropagation()}
|
<motion.div className="settings-panel" onClick={e => e.stopPropagation()}
|
||||||
role="dialog" aria-modal="true" aria-label={t('archive.title')}
|
role="dialog" aria-modal="true" aria-label={t('archive.title')}
|
||||||
|
onKeyDown={e => { if (e.key === 'Escape') onClose() }}
|
||||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||||
@@ -75,7 +76,7 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
<div className="archive-filters" style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 12 }}>
|
<div className="archive-filters" style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
{PRESETS.map(p => (
|
{PRESETS.map(p => (
|
||||||
<button key={p} className={`btn btn-sm${preset === p ? ' btn-active' : ''}`}
|
<button key={p} className={`btn btn-sm${preset === p ? ' btn-active' : ''}`} aria-pressed={preset === p}
|
||||||
onClick={() => setPreset(p)} style={{ textTransform: 'capitalize' }}>
|
onClick={() => setPreset(p)} style={{ textTransform: 'capitalize' }}>
|
||||||
{p === 'all' ? t('archive.all') : p}
|
{p === 'all' ? t('archive.all') : p}
|
||||||
</button>
|
</button>
|
||||||
@@ -88,7 +89,9 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
|
|||||||
)}
|
)}
|
||||||
<div className="cc-list">
|
<div className="cc-list">
|
||||||
{entries.map((entry, i) => (
|
{entries.map((entry, i) => (
|
||||||
<div key={`${entry.date}-${entry.preset}-${i}`} className="cc-clue" style={{ cursor: 'pointer' }} onClick={() => play(entry)}>
|
<div key={`${entry.date}-${entry.preset}-${i}`} className="cc-clue" style={{ cursor: 'pointer' }} role="button" tabIndex={0}
|
||||||
|
onClick={() => play(entry)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(entry) } }}>
|
||||||
<div className="cc-clue-text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div className="cc-clue-text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
{entry.solved && <IconCircleCheckFilled size={13} style={{ color: 'var(--color-correct)', flexShrink: 0 }} aria-hidden="true" />}
|
{entry.solved && <IconCircleCheckFilled size={13} style={{ color: 'var(--color-correct)', flexShrink: 0 }} aria-hidden="true" />}
|
||||||
<span>{entry.date}</span>
|
<span>{entry.date}</span>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { IconSunFilled, IconMoonFilled, IconDeviceDesktop, IconEyeFilled, IconTe
|
|||||||
IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight,
|
IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight,
|
||||||
IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled,
|
IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled,
|
||||||
IconAccessibleFilled } from '@tabler/icons-react'
|
IconAccessibleFilled } from '@tabler/icons-react'
|
||||||
import * as narrator from '../narrator'
|
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
@@ -91,68 +90,11 @@ function Toggle({ checked, onChange, label, hint, disabled, disabledHint }: { ch
|
|||||||
|
|
||||||
function NarratorSettingsBlock() {
|
function NarratorSettingsBlock() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const narratorEnabled = useStore(s => s.narratorEnabled)
|
|
||||||
const narratorSettings = useStore(s => s.narratorSettings)
|
const narratorSettings = useStore(s => s.narratorSettings)
|
||||||
const setNarratorEnabled = useStore(s => s.setNarratorEnabled)
|
|
||||||
const setNarratorSettings = useStore(s => s.setNarratorSettings)
|
const setNarratorSettings = useStore(s => s.setNarratorSettings)
|
||||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
|
||||||
const [pendingRate, setPendingRate] = useState(narratorSettings.rate)
|
|
||||||
const [pendingPitch, setPendingPitch] = useState(narratorSettings.pitch)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!narrator.isSupported()) return
|
|
||||||
const loadVoices = () => setVoices(narrator.getVoices())
|
|
||||||
loadVoices()
|
|
||||||
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
|
||||||
window.speechSynthesis.onvoiceschanged = loadVoices
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!narrator.isSupported()) return (
|
|
||||||
<p className="settings-hint">{t('settings.narrator.notSupported')}</p>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-narrator-controls">
|
<div className="settings-narrator-controls">
|
||||||
<Toggle checked={narratorEnabled} onChange={v => setNarratorEnabled(v)}
|
|
||||||
label={t('settings.narrator.narratorMode')} />
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{narratorEnabled && (
|
|
||||||
<motion.div className="settings-narrator-detail"
|
|
||||||
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
|
|
||||||
style={{ overflow: 'hidden' }}>
|
|
||||||
<div className="settings-narrator-grid">
|
|
||||||
<div className="settings-narrator-item">
|
|
||||||
<label className="settings-sublabel">{t('settings.narrator.speechRate', { rate: pendingRate.toFixed(1) })}</label>
|
|
||||||
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingRate}
|
|
||||||
onChange={e => setPendingRate(Number(e.target.value))}
|
|
||||||
onMouseUp={() => setNarratorSettings({ rate: pendingRate })}
|
|
||||||
onTouchEnd={() => setNarratorSettings({ rate: pendingRate })} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-narrator-item">
|
|
||||||
<label className="settings-sublabel">{t('settings.narrator.pitch', { pitch: pendingPitch.toFixed(1) })}</label>
|
|
||||||
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingPitch}
|
|
||||||
onChange={e => setPendingPitch(Number(e.target.value))}
|
|
||||||
onMouseUp={() => setNarratorSettings({ pitch: pendingPitch })}
|
|
||||||
onTouchEnd={() => setNarratorSettings({ pitch: pendingPitch })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{voices.length > 0 && (
|
|
||||||
<div className="settings-narrator-item">
|
|
||||||
<label className="settings-sublabel">{t('settings.narrator.voice')}</label>
|
|
||||||
<Select value={narratorSettings.voice}
|
|
||||||
onChange={v => setNarratorSettings({ voice: String(v) })}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('settings.narrator.defaultVoice') },
|
|
||||||
...voices.map(v => ({ value: v.name, label: `${v.name} (${v.lang})` }))
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="settings-narrator-item">
|
<div className="settings-narrator-item">
|
||||||
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
|
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
|
||||||
<Select value={narratorSettings.verbosity}
|
<Select value={narratorSettings.verbosity}
|
||||||
@@ -163,23 +105,8 @@ function NarratorSettingsBlock() {
|
|||||||
{ value: 'verbose', label: t('settings.narrator.verbose') },
|
{ value: 'verbose', label: t('settings.narrator.verbose') },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-narrator-item">
|
|
||||||
<label className="settings-sublabel">{t('settings.narrator.output')}</label>
|
|
||||||
<Select value={narratorSettings.mode}
|
|
||||||
onChange={v => { const s = String(v); if (s === 'voice' || s === 'screenreader' || s === 'both') setNarratorSettings({ mode: s }) }}
|
|
||||||
options={[
|
|
||||||
{ value: 'voice', label: t('settings.narrator.outputVoice') },
|
|
||||||
{ value: 'screenreader', label: t('settings.narrator.outputScreenReader') },
|
|
||||||
{ value: 'both', label: t('settings.narrator.outputBoth') },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
|
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
|
||||||
label={t('settings.narrator.soundEffects')} />
|
label={t('settings.narrator.soundEffects')} />
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,6 +125,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
const [newBlockedDomain, setNewBlockedDomain] = useState('')
|
const [newBlockedDomain, setNewBlockedDomain] = useState('')
|
||||||
const [tab, setTab] = useState<Tab>('profile')
|
const [tab, setTab] = useState<Tab>('profile')
|
||||||
const [migrateDomain, setMigrateDomain] = useState('')
|
const [migrateDomain, setMigrateDomain] = useState('')
|
||||||
|
const [importMsg, setImportMsg] = useState('')
|
||||||
const [migrating, setMigrating] = useState(false)
|
const [migrating, setMigrating] = useState(false)
|
||||||
const [migrateMsg, setMigrateMsg] = useState('')
|
const [migrateMsg, setMigrateMsg] = useState('')
|
||||||
const [displayName, setDisplayName] = useState('')
|
const [displayName, setDisplayName] = useState('')
|
||||||
@@ -303,7 +231,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<label className="settings-label">{t('settings.display.theme')}</label>
|
<label className="settings-label">{t('settings.display.theme')}</label>
|
||||||
<div className="settings-row">
|
<div className="settings-row">
|
||||||
{(['system', 'light', 'dark'] as const).map(th => (
|
{(['system', 'light', 'dark'] as const).map(th => (
|
||||||
<button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} onClick={() => update({ theme: th })}>
|
<button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} aria-pressed={settings.theme === th} onClick={() => update({ theme: th })}>
|
||||||
{th === 'system' ? <><IconDeviceDesktop size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.system')}</> :
|
{th === 'system' ? <><IconDeviceDesktop size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.system')}</> :
|
||||||
th === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> :
|
th === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> :
|
||||||
<><IconMoonFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.dark')}</>}
|
<><IconMoonFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.dark')}</>}
|
||||||
@@ -316,7 +244,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<label className="settings-label"><IconKeyboardFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.display.inputMode')}</label>
|
<label className="settings-label"><IconKeyboardFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.display.inputMode')}</label>
|
||||||
<div className="settings-row">
|
<div className="settings-row">
|
||||||
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
|
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
|
||||||
<button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`}
|
<button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`} aria-pressed={inputMode === m}
|
||||||
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
|
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
|
||||||
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
|
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
|
||||||
</button>
|
</button>
|
||||||
@@ -399,7 +327,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<label className="settings-label"><IconCookieFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.duration')}</label>
|
<label className="settings-label"><IconCookieFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.duration')}</label>
|
||||||
<div className="settings-grid-3">
|
<div className="settings-grid-3">
|
||||||
{LIFETIME_KEYS.map(opt => (
|
{LIFETIME_KEYS.map(opt => (
|
||||||
<button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`}
|
<button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`} aria-pressed={settings.cookieLifetime === opt.value}
|
||||||
onClick={() => update({ cookieLifetime: opt.value })}>{t(opt.key)}</button>
|
onClick={() => update({ cookieLifetime: opt.value })}>{t(opt.key)}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +348,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
) : (
|
) : (
|
||||||
<p className="settings-hint">{t('settings.session.noPasskeys')}</p>
|
<p className="settings-hint">{t('settings.session.noPasskeys')}</p>
|
||||||
)}
|
)}
|
||||||
{passkeyError && <p className="settings-hint" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>}
|
{passkeyError && <p className="settings-hint" role="alert" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>}
|
||||||
{supportsPasskey && (
|
{supportsPasskey && (
|
||||||
<button className="btn btn-sm" onClick={addPasskey} disabled={registering} style={{ marginTop: 8 }}>
|
<button className="btn btn-sm" onClick={addPasskey} disabled={registering} style={{ marginTop: 8 }}>
|
||||||
<IconKeyFilled size={13} style={{ verticalAlign: -2 }} /> {registering ? t('settings.session.registering') : t('settings.session.addPasskey')}
|
<IconKeyFilled size={13} style={{ verticalAlign: -2 }} /> {registering ? t('settings.session.registering') : t('settings.session.addPasskey')}
|
||||||
@@ -518,7 +446,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{settings.blockedInstances.length > 0 && (
|
{settings.blockedInstances.length > 0 && (
|
||||||
<div className="privacy-blocked-list">
|
<div className="privacy-blocked-list">
|
||||||
{settings.blockedInstances.map(d => (
|
{settings.blockedInstances.map(d => (
|
||||||
<span key={d} className="privacy-blocked-tag">{d}<button onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} /></button></span>
|
<span key={d} className="privacy-blocked-tag">{d}<button aria-label="Remove" onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} aria-hidden="true" /></button></span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -557,14 +485,14 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
const res = await api.importData(data)
|
const res = await api.importData(data)
|
||||||
let msg = t('onboarding.imported', { count: res.imported })
|
let msg = t('onboarding.imported', { count: res.imported })
|
||||||
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
|
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
|
||||||
alert(msg)
|
setImportMsg(msg)
|
||||||
window.location.reload()
|
} catch (e) { setImportMsg(e instanceof Error ? e.message : t('onboarding.importFailed')) }
|
||||||
} catch (e) { alert(e instanceof Error ? e.message : t('onboarding.importFailed')) }
|
|
||||||
}; input.click()
|
}; input.click()
|
||||||
}}>
|
}}>
|
||||||
{t('settings.account.import')}
|
{t('settings.account.import')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{importMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{importMsg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
@@ -597,7 +525,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
}}>{claimingManual ? t('settings.account.claiming') : t('settings.account.claim')}</button>
|
}}>{claimingManual ? t('settings.account.claiming') : t('settings.account.claim')}</button>
|
||||||
</div>
|
</div>
|
||||||
{claimStatus && (
|
{claimStatus && (
|
||||||
<p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`}>
|
<p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`} role="status">
|
||||||
{claimStatus.msg}
|
{claimStatus.msg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -637,7 +565,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|||||||
.finally(() => setMigrating(false))
|
.finally(() => setMigrating(false))
|
||||||
}}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button>
|
}}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button>
|
||||||
</div>
|
</div>
|
||||||
{migrateMsg && <p className="settings-hint" style={{ marginTop: 8 }}>{migrateMsg}</p>}
|
{migrateMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{migrateMsg}</p>}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,23 @@ export default function SolveHeatmap() {
|
|||||||
return 'var(--heatmap-clean, #22c55e)'
|
return 'var(--heatmap-clean, #22c55e)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let clean = 0
|
||||||
|
let hinted = 0
|
||||||
|
let wrong = 0
|
||||||
|
let blocked = 0
|
||||||
|
for (let r = 0; r < puzzle.height; r++) {
|
||||||
|
for (let c = 0; c < puzzle.width; c++) {
|
||||||
|
if (puzzle.cells[r][c] === 'black') { blocked++; continue }
|
||||||
|
if (cellStatus[r]?.[c] === 'revealed') { hinted++; continue }
|
||||||
|
if (cellEverWrong[r]?.[c]) { wrong++; continue }
|
||||||
|
clean++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="solve-heatmap">
|
<div className="solve-heatmap">
|
||||||
<h4 className="solve-heatmap-title">{t('heatmap.title')}</h4>
|
<h4 className="solve-heatmap-title">{t('heatmap.title')}</h4>
|
||||||
|
<span className="visually-hidden">{t('heatmap.title')}: {clean} {t('heatmap.clean')}, {hinted} {t('heatmap.hinted')}, {wrong} {t('heatmap.wrong')}</span>
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="solve-heatmap-grid">
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="solve-heatmap-grid">
|
||||||
{Array.from({ length: puzzle.height }, (_, r) =>
|
{Array.from({ length: puzzle.height }, (_, r) =>
|
||||||
Array.from({ length: puzzle.width }, (_, c) => (
|
Array.from({ length: puzzle.width }, (_, c) => (
|
||||||
|
|||||||
@@ -66,11 +66,18 @@ function RichSelect({ value, options, onChange, label }: {
|
|||||||
}
|
}
|
||||||
}, [open, updatePos])
|
}, [open, updatePos])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
dropRef.current?.querySelector<HTMLElement>('button')?.focus()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ed-rich-select">
|
<div className="ed-rich-select">
|
||||||
<label className="ed-select-label">{label}</label>
|
<label className="ed-select-label">{label}</label>
|
||||||
<button ref={triggerRef} type="button" className="ed-rich-trigger" onClick={() => setOpen(!open)}
|
<button ref={triggerRef} type="button" className="ed-rich-trigger" onClick={() => setOpen(!open)}
|
||||||
aria-haspopup="listbox" aria-expanded={open}>
|
aria-haspopup="listbox" aria-expanded={open}
|
||||||
|
onKeyDown={e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); setOpen(true) } }}>
|
||||||
{selected?.icon && <selected.icon size={13} className="ed-rich-trigger-icon" aria-hidden="true" />}
|
{selected?.icon && <selected.icon size={13} className="ed-rich-trigger-icon" aria-hidden="true" />}
|
||||||
<span className="ed-rich-trigger-label">{selected?.label || ''}</span>
|
<span className="ed-rich-trigger-label">{selected?.label || ''}</span>
|
||||||
<IconChevronDownFilled size={9} className="ed-rich-trigger-chevron" style={{ transform: open ? 'rotate(180deg)' : 'none' }} aria-hidden="true" />
|
<IconChevronDownFilled size={9} className="ed-rich-trigger-chevron" style={{ transform: open ? 'rotate(180deg)' : 'none' }} aria-hidden="true" />
|
||||||
@@ -243,7 +250,7 @@ export default function Welcome() {
|
|||||||
|
|
||||||
{/* custom puzzle */}
|
{/* custom puzzle */}
|
||||||
<div className="ed-drawer">
|
<div className="ed-drawer">
|
||||||
<button type="button" className="ed-drawer-toggle" onClick={() => setShowCustomPanel(!showCustomPanel)}>
|
<button type="button" className="ed-drawer-toggle" aria-expanded={showCustomPanel} onClick={() => setShowCustomPanel(!showCustomPanel)}>
|
||||||
<h2>{t('welcome.customPuzzle')}</h2>
|
<h2>{t('welcome.customPuzzle')}</h2>
|
||||||
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCustomPanel ? 'rotate(180deg)' : 'none' }} />
|
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCustomPanel ? 'rotate(180deg)' : 'none' }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -373,7 +380,7 @@ export default function Welcome() {
|
|||||||
{/* community puzzles */}
|
{/* community puzzles */}
|
||||||
{allCommunity.length > 0 && (
|
{allCommunity.length > 0 && (
|
||||||
<div className="ed-drawer">
|
<div className="ed-drawer">
|
||||||
<button type="button" className="ed-drawer-toggle" onClick={() => setShowCommunity(!showCommunity)}>
|
<button type="button" className="ed-drawer-toggle" aria-expanded={showCommunity} onClick={() => setShowCommunity(!showCommunity)}>
|
||||||
<h2>{t('welcome.communityPuzzles')}</h2><span className="ed-drawer-badge">{allCommunity.length}</span>
|
<h2>{t('welcome.communityPuzzles')}</h2><span className="ed-drawer-badge">{allCommunity.length}</span>
|
||||||
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCommunity ? 'rotate(180deg)' : 'none' }} />
|
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCommunity ? 'rotate(180deg)' : 'none' }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -385,15 +392,16 @@ export default function Welcome() {
|
|||||||
style={{ overflow: 'hidden' }}>
|
style={{ overflow: 'hidden' }}>
|
||||||
<div className="ed-puzzle-grid">
|
<div className="ed-puzzle-grid">
|
||||||
{allCommunity.slice(0, 8).map(p => (
|
{allCommunity.slice(0, 8).map(p => (
|
||||||
<button key={p.id} type="button" className="ed-puzzle-card"
|
<div key={p.id} className="ed-puzzle-card" role="button" tabIndex={0}
|
||||||
onClick={() => !loading && loadCommunityPuzzle(p.id)} disabled={loading}>
|
onClick={() => !loading && loadCommunityPuzzle(p.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!loading) loadCommunityPuzzle(p.id) } }}>
|
||||||
<span className="ed-puzzle-size">{p.width}x{p.height}</span>
|
<span className="ed-puzzle-size">{p.width}x{p.height}</span>
|
||||||
<span className="ed-puzzle-title">{p.title}</span>
|
<span className="ed-puzzle-title">{p.title}</span>
|
||||||
<span className="ed-puzzle-by">
|
<span className="ed-puzzle-by">
|
||||||
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
<span className="ed-puzzle-report" onClick={e => e.stopPropagation()}><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -38,11 +38,18 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const calRef = useRef<HTMLDivElement>(null)
|
const calRef = useRef<HTMLDivElement>(null)
|
||||||
|
const dayRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
const [focusDay, setFocusDay] = useState<number | null>(null)
|
||||||
const { year, month } = parse(value)
|
const { year, month } = parse(value)
|
||||||
const [viewYear, setViewYear] = useState(year)
|
const [viewYear, setViewYear] = useState(year)
|
||||||
const [viewMonth, setViewMonth] = useState(month)
|
const [viewMonth, setViewMonth] = useState(month)
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
|
||||||
|
// the day of the selected value, when it falls inside the current view
|
||||||
|
const selected = parse(value)
|
||||||
|
const selectedInView =
|
||||||
|
selected.year === viewYear && selected.month === viewMonth ? selected.day : 1
|
||||||
|
|
||||||
const updatePos = useCallback(() => {
|
const updatePos = useCallback(() => {
|
||||||
if (!triggerRef.current) return
|
if (!triggerRef.current) return
|
||||||
const rect = triggerRef.current.getBoundingClientRect()
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
@@ -60,7 +67,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') setOpen(false)
|
if (e.key === 'Escape') { setFocusDay(null); setOpen(false); triggerRef.current?.focus() }
|
||||||
}
|
}
|
||||||
window.addEventListener('scroll', updatePos, true)
|
window.addEventListener('scroll', updatePos, true)
|
||||||
document.addEventListener('mousedown', onDown)
|
document.addEventListener('mousedown', onDown)
|
||||||
@@ -87,12 +94,38 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
|||||||
|
|
||||||
const pick = (d: number) => {
|
const pick = (d: number) => {
|
||||||
onChange(fmt(viewYear, viewMonth, d))
|
onChange(fmt(viewYear, viewMonth, d))
|
||||||
|
setFocusDay(null)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusDayRef = (d: number) => {
|
||||||
|
setFocusDay(d)
|
||||||
|
dayRefs.current[d - 1]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridKeyDown = (e: { key: string; preventDefault(): void }) => {
|
||||||
|
const cur = focusDay != null ? focusDay : selectedInView
|
||||||
|
let next = cur
|
||||||
|
if (e.key === 'ArrowRight') next = cur + 1
|
||||||
|
else if (e.key === 'ArrowLeft') next = cur - 1
|
||||||
|
else if (e.key === 'ArrowDown') next = cur + 7
|
||||||
|
else if (e.key === 'ArrowUp') next = cur - 7
|
||||||
|
else return
|
||||||
|
e.preventDefault()
|
||||||
|
if (next < 1 || next > days) return
|
||||||
|
focusDayRef(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const d = focusDay != null && focusDay >= 1 && focusDay <= days ? focusDay : selectedInView
|
||||||
|
dayRefs.current[d - 1]?.focus()
|
||||||
|
}, [open, days, selectedInView])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`cv-datepicker ${className || ''}`}>
|
<div className={`cv-datepicker ${className || ''}`}>
|
||||||
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button">
|
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
|
||||||
|
aria-haspopup="dialog" aria-expanded={open}>
|
||||||
<span>{value}</span>
|
<span>{value}</span>
|
||||||
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -107,7 +140,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
|||||||
<div className="cv-cal-days" role="grid">
|
<div className="cv-cal-days" role="grid">
|
||||||
{WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)}
|
{WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="cv-cal-grid" role="grid">
|
<div className="cv-cal-grid" role="grid" onKeyDown={onGridKeyDown}>
|
||||||
{Array.from({ length: offset }, (_, i) => <span key={`pad-${i}`} />)}
|
{Array.from({ length: offset }, (_, i) => <span key={`pad-${i}`} />)}
|
||||||
{Array.from({ length: days }, (_, i) => {
|
{Array.from({ length: days }, (_, i) => {
|
||||||
const d = i + 1
|
const d = i + 1
|
||||||
@@ -117,8 +150,10 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
|
ref={el => { dayRefs.current[d - 1] = el }}
|
||||||
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
|
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
|
||||||
onClick={() => pick(d)}
|
onClick={() => pick(d)}
|
||||||
|
onFocus={() => setFocusDay(d)}
|
||||||
type="button"
|
type="button"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: number
|
value: number
|
||||||
min?: number
|
min?: number
|
||||||
@@ -9,14 +11,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NumberInput({ value, min = 0, max = 999, onChange, className, style, 'aria-label': ariaLabel }: Props) {
|
export default function NumberInput({ value, min = 0, max = 999, onChange, className, style, 'aria-label': ariaLabel }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const clamp = (n: number) => Math.min(max, Math.max(min, n))
|
const clamp = (n: number) => Math.min(max, Math.max(min, n))
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
let next: number | null = null
|
||||||
|
if (e.key === 'ArrowUp') next = value + 1
|
||||||
|
else if (e.key === 'ArrowDown') next = value - 1
|
||||||
|
else if (e.key === 'PageUp') next = value + 10
|
||||||
|
else if (e.key === 'PageDown') next = value - 10
|
||||||
|
else if (e.key === 'Home') next = min
|
||||||
|
else if (e.key === 'End') next = max
|
||||||
|
if (next === null) return
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(clamp(next))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`cv-number ${className || ''}`} style={style}
|
<div className={`cv-number ${className || ''}`} style={style}
|
||||||
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel}>
|
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max}
|
||||||
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min} aria-label="Decrease">-</button>
|
aria-label={ariaLabel} tabIndex={0} onKeyDown={onKeyDown}>
|
||||||
<span className="cv-number-value">{value}</span>
|
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min}
|
||||||
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max} aria-label="Increase">+</button>
|
aria-label={t('ui.decrease')} tabIndex={-1}>-</button>
|
||||||
|
<span className="cv-number-value" aria-hidden="true">{value}</span>
|
||||||
|
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max}
|
||||||
|
aria-label={t('ui.increase')} tabIndex={-1}>+</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,14 @@ export default function Select({ value, options, onChange, className, style }: P
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const dropRef = useRef<HTMLDivElement>(null)
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||||
const listboxId = useId()
|
const listboxId = useId()
|
||||||
|
|
||||||
const selected = options.find(o => String(o.value) === String(value))
|
const selected = options.find(o => String(o.value) === String(value))
|
||||||
|
const selectedIndex = options.findIndex(o => String(o.value) === String(value))
|
||||||
|
|
||||||
|
const close = useCallback(() => setOpen(false), [])
|
||||||
|
|
||||||
const updatePos = useCallback(() => {
|
const updatePos = useCallback(() => {
|
||||||
if (!triggerRef.current) return
|
if (!triggerRef.current) return
|
||||||
@@ -30,9 +34,16 @@ export default function Select({ value, options, onChange, className, style }: P
|
|||||||
setPos({ top: rect.bottom, left: rect.left, width: rect.width })
|
setPos({ top: rect.bottom, left: rect.left, width: rect.width })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// move real focus to the option matching the current value (or the first)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
updatePos()
|
updatePos()
|
||||||
|
const target = optionRefs.current[selectedIndex >= 0 ? selectedIndex : 0]
|
||||||
|
target?.focus()
|
||||||
|
}, [open, selectedIndex, updatePos])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
function onDown(e: MouseEvent) {
|
function onDown(e: MouseEvent) {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) &&
|
if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) &&
|
||||||
@@ -40,36 +51,75 @@ export default function Select({ value, options, onChange, className, style }: P
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') setOpen(false)
|
|
||||||
}
|
|
||||||
window.addEventListener('scroll', updatePos, true)
|
window.addEventListener('scroll', updatePos, true)
|
||||||
document.addEventListener('mousedown', onDown)
|
document.addEventListener('mousedown', onDown)
|
||||||
document.addEventListener('keydown', onKey)
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', updatePos, true)
|
window.removeEventListener('scroll', updatePos, true)
|
||||||
document.removeEventListener('mousedown', onDown)
|
document.removeEventListener('mousedown', onDown)
|
||||||
document.removeEventListener('keydown', onKey)
|
|
||||||
}
|
}
|
||||||
}, [open, updatePos])
|
}, [open, updatePos])
|
||||||
|
|
||||||
|
const moveFocus = useCallback((delta: number) => {
|
||||||
|
const refs = optionRefs.current
|
||||||
|
const current = refs.findIndex(el => el === document.activeElement)
|
||||||
|
const next = Math.min(refs.length - 1, Math.max(0, (current >= 0 ? current : 0) + delta))
|
||||||
|
refs[next]?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// trigger-level keys: arrows cycle values like a native select while closed
|
||||||
|
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
const delta = e.key === 'ArrowDown' ? 1 : -1
|
||||||
|
const next = Math.min(options.length - 1, Math.max(0, selectedIndex + delta))
|
||||||
|
if (next !== selectedIndex && options[next]) onChange(options[next].value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(!open)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOptionKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(1); return }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(-1); return }
|
||||||
|
if (e.key === 'Home') { e.preventDefault(); optionRefs.current[0]?.focus(); return }
|
||||||
|
if (e.key === 'End') { e.preventDefault(); optionRefs.current[options.length - 1]?.focus(); return }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); close(); triggerRef.current?.focus(); return }
|
||||||
|
if (e.key === 'Tab') { close(); return }
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
const o = options[index]
|
||||||
|
if (o) { onChange(o.value); close(); triggerRef.current?.focus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`cv-select ${className || ''}`} style={style}>
|
<div className={`cv-select ${className || ''}`} style={style}>
|
||||||
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
|
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
|
||||||
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}>
|
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}
|
||||||
|
onKeyDown={onTriggerKeyDown}>
|
||||||
<span>{selected?.label || String(value)}</span>
|
<span>{selected?.label || String(value)}</span>
|
||||||
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
||||||
</button>
|
</button>
|
||||||
{open && createPortal(
|
{open && createPortal(
|
||||||
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
|
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
|
||||||
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
|
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
|
||||||
{options.map(o => (
|
{options.map((o, i) => (
|
||||||
<button
|
<button
|
||||||
key={String(o.value)}
|
key={String(o.value)}
|
||||||
|
ref={el => { optionRefs.current[i] = el }}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={String(o.value) === String(value)}
|
aria-selected={String(o.value) === String(value)}
|
||||||
|
tabIndex={i === (selectedIndex >= 0 ? selectedIndex : 0) ? 0 : -1}
|
||||||
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
|
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
|
||||||
onClick={() => { onChange(o.value); setOpen(false) }}
|
onClick={() => { onChange(o.value); close(); triggerRef.current?.focus() }}
|
||||||
|
onKeyDown={e => onOptionKeyDown(e, i)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useCallback, useEffect, useId } from 'react'
|
import { useState, useRef, useCallback, useEffect, useId, isValidElement, cloneElement } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -59,6 +59,11 @@ export default function Tooltip({ text, children, delay = 400 }: Props) {
|
|||||||
|
|
||||||
if (!text) return <>{children}</>
|
if (!text) return <>{children}</>
|
||||||
|
|
||||||
|
// SAFETY: isValidElement checked children is an element before this cast.
|
||||||
|
const child = isValidElement(children)
|
||||||
|
? cloneElement(children as React.ReactElement<{ 'aria-describedby'?: string }>, { 'aria-describedby': visible ? tipId : undefined })
|
||||||
|
: children
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -66,10 +71,9 @@ export default function Tooltip({ text, children, delay = 400 }: Props) {
|
|||||||
onMouseLeave={hide}
|
onMouseLeave={hide}
|
||||||
onFocus={show}
|
onFocus={show}
|
||||||
onBlur={hide}
|
onBlur={hide}
|
||||||
aria-describedby={visible ? tipId : undefined}
|
|
||||||
className="cv-tooltip-wrap"
|
className="cv-tooltip-wrap"
|
||||||
>
|
>
|
||||||
{children}
|
{child}
|
||||||
{visible && createPortal(
|
{visible && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={tipRef}
|
ref={tipRef}
|
||||||
|
|||||||
@@ -1,107 +1,34 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import type { Direction } from '../types'
|
import i18n from '../i18n'
|
||||||
import * as narrator from '../narrator'
|
import { announce } from '../narrator'
|
||||||
|
|
||||||
function readCurrentCell() {
|
|
||||||
const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState()
|
|
||||||
if (!puzzle || !selectedCell) {
|
|
||||||
narrator.speak('No cell selected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { row, col } = selectedCell
|
|
||||||
const letter = entries[row]?.[col] || ''
|
|
||||||
const num = puzzle.cell_numbers[`${row},${col}`]
|
|
||||||
const span = wordSpans.find(s => s.number === activeClue && s.direction === direction)
|
|
||||||
const pos = span ? span.cells.findIndex(([r, c]) => r === row && c === col) + 1 : 0
|
|
||||||
const len = span?.cells.length || 0
|
|
||||||
|
|
||||||
let msg = `Row ${row + 1}, Column ${col + 1}`
|
|
||||||
if (num) msg += `, number ${num}`
|
|
||||||
msg += letter ? `, letter ${letter}` : ', empty'
|
|
||||||
msg += `, ${direction}`
|
|
||||||
if (pos && len) msg += `, position ${pos} of ${len}`
|
|
||||||
narrator.speak(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readWord(dir: Direction) {
|
|
||||||
const { puzzle, selectedCell, entries, wordSpans } = useStore.getState()
|
|
||||||
if (!puzzle || !selectedCell) {
|
|
||||||
narrator.speak('No cell selected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const span = wordSpans.find(s =>
|
|
||||||
s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col)
|
|
||||||
)
|
|
||||||
if (!span) {
|
|
||||||
narrator.speak(`No ${dir} word here`, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const letters = span.cells.map(([r, c]) => entries[r]?.[c] || 'blank')
|
|
||||||
narrator.speak(`${dir === 'across' ? 'Across' : 'Down'} ${span.number}, ${span.cells.length} letters: ${letters.join(', ')}.`, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCurrentClue() {
|
|
||||||
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
|
|
||||||
if (!puzzle || activeClue === null) {
|
|
||||||
narrator.speak('No clue selected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
|
|
||||||
const clue = clues.find(c => c.number === activeClue)
|
|
||||||
if (!clue) return
|
|
||||||
|
|
||||||
const span = wordSpans.find(s => s.number === activeClue && s.direction === direction)
|
|
||||||
const len = span?.cells.length || 0
|
|
||||||
const filled = span ? span.cells.filter(([r, c]) => entries[r]?.[c]).length : 0
|
|
||||||
const v = narratorSettings.verbosity
|
|
||||||
|
|
||||||
if (v === 'brief') {
|
|
||||||
narrator.speak(`${activeClue} ${direction}: ${clue.text}`)
|
|
||||||
} else if (v === 'normal') {
|
|
||||||
narrator.speak(`${activeClue} ${direction}, ${len} letters: ${clue.text}`)
|
|
||||||
} else {
|
|
||||||
narrator.speak(`${activeClue} ${direction}, ${len} letters, ${filled} filled: ${clue.text}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPuzzleOverview() {
|
|
||||||
const { puzzle, entries, wordSpans } = useStore.getState()
|
|
||||||
if (!puzzle) {
|
|
||||||
narrator.speak('No puzzle loaded')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let totalCells = 0
|
|
||||||
let filledCells = 0
|
|
||||||
for (let r = 0; r < puzzle.height; r++) {
|
|
||||||
for (let c = 0; c < puzzle.width; c++) {
|
|
||||||
if (puzzle.cells[r][c] !== 'black') {
|
|
||||||
totalCells++
|
|
||||||
if (entries[r]?.[c]) filledCells++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const totalClues = puzzle.clues_across.length + puzzle.clues_down.length
|
|
||||||
const completedWords = wordSpans.filter(s =>
|
|
||||||
s.cells.every(([r, c]) => entries[r]?.[c])
|
|
||||||
).length
|
|
||||||
const remaining = totalClues - completedWords
|
|
||||||
|
|
||||||
narrator.speak(
|
|
||||||
`${puzzle.width} by ${puzzle.height} puzzle, ${filledCells} of ${totalCells} cells filled, ${remaining} clues remaining`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGridEl(): HTMLElement | null {
|
function getGridEl(): HTMLElement | null {
|
||||||
return document.querySelector('.grid-wrapper')
|
return document.querySelector('.grid-sr-grid')
|
||||||
}
|
}
|
||||||
|
|
||||||
// tracks whether focus (or the last pointer interaction) is inside the grid,
|
// Announce the current clue and the fill pattern of its word (e.g. "1 Across,
|
||||||
// so typing after clicking a header button does not fill grid cells
|
// 6 letters: P, blank, blank, blank, blank, T. What is a baby boar called.").
|
||||||
let gridHasFocus = false
|
function readWordPattern() {
|
||||||
|
const t = i18n.t
|
||||||
function updateGridFocus(target: EventTarget | null) {
|
const { puzzle, wordSpans, entries, activeClue, direction } = useStore.getState()
|
||||||
gridHasFocus = !!getGridEl()?.contains(document.activeElement) || (target instanceof Element && !!getGridEl()?.contains(target))
|
if (!puzzle || activeClue === null) {
|
||||||
|
announce(t('a11y.noClueSelected'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const span = wordSpans.find((s) => s.number === activeClue && s.direction === direction)
|
||||||
|
if (!span) return
|
||||||
|
const pattern = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.blank')).join(', ')
|
||||||
|
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
|
||||||
|
const clue = clues.find((c) => c.number === activeClue)
|
||||||
|
const dir = t(direction === 'across' ? 'game.across' : 'game.down')
|
||||||
|
announce(t('a11y.wordPattern', {
|
||||||
|
number: activeClue,
|
||||||
|
dir,
|
||||||
|
length: span.cells.length,
|
||||||
|
pattern,
|
||||||
|
clue: clue ? clue.text : '',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboard() {
|
export function useKeyboard() {
|
||||||
@@ -109,100 +36,42 @@ export function useKeyboard() {
|
|||||||
const typeLetter = useStore((s) => s.typeLetter)
|
const typeLetter = useStore((s) => s.typeLetter)
|
||||||
const deleteLetter = useStore((s) => s.deleteLetter)
|
const deleteLetter = useStore((s) => s.deleteLetter)
|
||||||
const moveSelection = useStore((s) => s.moveSelection)
|
const moveSelection = useStore((s) => s.moveSelection)
|
||||||
|
const moveRowEdge = useStore((s) => s.moveRowEdge)
|
||||||
const toggleDirection = useStore((s) => s.toggleDirection)
|
const toggleDirection = useStore((s) => s.toggleDirection)
|
||||||
const tabClue = useStore((s) => s.tabClue)
|
|
||||||
const narratorEnabled = useStore((s) => s.narratorEnabled)
|
|
||||||
const undo = useStore((s) => s.undo)
|
const undo = useStore((s) => s.undo)
|
||||||
const redo = useStore((s) => s.redo)
|
const redo = useStore((s) => s.redo)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!puzzle) return
|
if (!puzzle) return
|
||||||
|
const grid = getGridEl()
|
||||||
gridHasFocus = !!getGridEl()?.contains(document.activeElement)
|
if (!grid) return
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent) => updateGridFocus(e.target)
|
|
||||||
const onFocusIn = (e: FocusEvent) => updateGridFocus(e.target)
|
|
||||||
document.addEventListener('pointerdown', onPointerDown, true)
|
|
||||||
document.addEventListener('focusin', onFocusIn)
|
|
||||||
|
|
||||||
// give the grid initial keyboard focus when nothing else is focused
|
|
||||||
if (document.activeElement === document.body || document.activeElement === null) {
|
|
||||||
const firstCell = getGridEl()?.querySelector('button.grid-sr-cell')
|
|
||||||
if (firstCell instanceof HTMLButtonElement) firstCell.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (
|
|
||||||
e.target instanceof HTMLInputElement ||
|
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
|
||||||
e.target instanceof HTMLSelectElement
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
// only handle letter input when the grid area has focus
|
|
||||||
const gridFocused = gridHasFocus || !!getGridEl()?.contains(document.activeElement)
|
|
||||||
|
|
||||||
const key = e.key
|
const key = e.key
|
||||||
const narr = useStore.getState().narratorEnabled
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
// narrator-only shortcuts - only when grid is focused
|
if (ctrl && key.toLowerCase() === 'z' && !e.shiftKey) {
|
||||||
if (narr && gridFocused) {
|
|
||||||
if (key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
readCurrentCell()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key === '?' || key === '/') {
|
|
||||||
e.preventDefault()
|
|
||||||
readCurrentClue()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey && key.toLowerCase() === 'h') || key === 'F1') {
|
|
||||||
e.preventDefault()
|
|
||||||
readPuzzleOverview()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.altKey && key.toLowerCase() === 'a') {
|
|
||||||
e.preventDefault()
|
|
||||||
readWord('across')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.altKey && key.toLowerCase() === 'd') {
|
|
||||||
e.preventDefault()
|
|
||||||
readWord('down')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key === 'Tab') {
|
|
||||||
e.preventDefault()
|
|
||||||
tabClue(!e.shiftKey)
|
|
||||||
// clue announcement handled by useNarrator hook
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gridFocused && e.ctrlKey && key.toLowerCase() === 'z' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
undo()
|
undo()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (gridFocused && ((e.ctrlKey && key.toLowerCase() === 'y') || (e.ctrlKey && e.shiftKey && key.toLowerCase() === 'z'))) {
|
if (ctrl && (key.toLowerCase() === 'y' || (e.shiftKey && key.toLowerCase() === 'z'))) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
redo()
|
redo()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (e.altKey && key.toLowerCase() === 'w') {
|
||||||
|
e.preventDefault()
|
||||||
|
readWordPattern()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (gridFocused && key.length === 1 && key >= 'a' && key <= 'z') {
|
if (!e.ctrlKey && !e.metaKey && !e.altKey && key.length === 1 &&
|
||||||
|
((key >= 'a' && key <= 'z') || (key >= 'A' && key <= 'Z'))) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
typeLetter(key)
|
typeLetter(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (gridFocused && key.length === 1 && key >= 'A' && key <= 'Z') {
|
|
||||||
e.preventDefault()
|
|
||||||
typeLetter(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gridFocused) return
|
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'Backspace':
|
case 'Backspace':
|
||||||
@@ -229,22 +98,22 @@ export function useKeyboard() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
moveSelection(0, 1)
|
moveSelection(0, 1)
|
||||||
break
|
break
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault()
|
||||||
|
moveRowEdge(true)
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault()
|
||||||
|
moveRowEdge(false)
|
||||||
|
break
|
||||||
case ' ':
|
case ' ':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleDirection()
|
toggleDirection()
|
||||||
break
|
break
|
||||||
case 'Tab':
|
|
||||||
e.preventDefault()
|
|
||||||
tabClue(!e.shiftKey)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
grid.addEventListener('keydown', onKeyDown)
|
||||||
return () => {
|
return () => grid.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
}, [puzzle, typeLetter, deleteLetter, moveSelection, moveRowEdge, toggleDirection, undo, redo])
|
||||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
||||||
document.removeEventListener('focusin', onFocusIn)
|
|
||||||
}
|
|
||||||
}, [puzzle, typeLetter, deleteLetter, moveSelection, toggleDirection, tabClue, narratorEnabled, undo, redo])
|
|
||||||
}
|
}
|
||||||
@@ -1,86 +1,65 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import * as narrator from '../narrator'
|
import i18n from '../i18n'
|
||||||
|
import { announce } from '../narrator'
|
||||||
|
|
||||||
function formatTime(secs: number): string {
|
function formatTime(secs: number, t: (key: string, opts?: { count: number }) => string): string {
|
||||||
const m = Math.floor(secs / 60)
|
const m = Math.floor(secs / 60)
|
||||||
const s = secs % 60
|
const s = secs % 60
|
||||||
if (m === 0) return `${s} second${s !== 1 ? 's' : ''}`
|
if (m === 0) return t('a11y.seconds', { count: s })
|
||||||
if (s === 0) return `${m} minute${m !== 1 ? 's' : ''}`
|
if (s === 0) return t('a11y.minutes', { count: m })
|
||||||
return `${m} minute${m !== 1 ? 's' : ''} ${s} second${s !== 1 ? 's' : ''}`
|
return `${t('a11y.minutes', { count: m })} ${t('a11y.seconds', { count: s })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNarrator() {
|
export function useNarrator() {
|
||||||
const prevCell = useRef<{ row: number; col: number } | null>(null)
|
const prevClueKey = useRef<string | null>(null)
|
||||||
const prevDirection = useRef<string | null>(null)
|
|
||||||
const prevEntries = useRef<string[][] | null>(null)
|
const prevEntries = useRef<string[][] | null>(null)
|
||||||
const prevActiveClue = useRef<number | null>(null)
|
|
||||||
const prevSolved = useRef(false)
|
const prevSolved = useRef(false)
|
||||||
|
|
||||||
// sync settings to narrator module whenever they change
|
|
||||||
const narratorSettings = useStore(s => s.narratorSettings)
|
|
||||||
useEffect(() => {
|
|
||||||
narrator.updateSettings(narratorSettings)
|
|
||||||
}, [narratorSettings])
|
|
||||||
|
|
||||||
// subscribe to store changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useStore.subscribe((state, prev) => {
|
const unsub = useStore.subscribe((state, prev) => {
|
||||||
if (!state.narratorEnabled || !state.puzzle) return
|
if (!state.puzzle) return
|
||||||
|
const t = i18n.t
|
||||||
const v = state.narratorSettings.verbosity
|
const v = state.narratorSettings.verbosity
|
||||||
|
|
||||||
// direction change
|
// Active clue change (moving into a new word or toggling direction).
|
||||||
if (state.direction !== prevDirection.current && prevDirection.current !== null) {
|
if (state.activeClue !== null) {
|
||||||
narrator.speak(`Now solving ${state.direction}`)
|
const key = `${state.activeClue}-${state.direction}`
|
||||||
}
|
if (key !== prevClueKey.current) {
|
||||||
prevDirection.current = state.direction
|
const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
|
||||||
|
const clue = clues.find((c) => c.number === state.activeClue)
|
||||||
// cell selection
|
if (clue) {
|
||||||
if (state.selectedCell &&
|
const span = state.wordSpans.find((s) => s.number === state.activeClue && s.direction === state.direction)
|
||||||
(state.selectedCell.row !== prevCell.current?.row ||
|
const len = span?.cells.length || 0
|
||||||
state.selectedCell.col !== prevCell.current?.col)) {
|
const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
|
||||||
|
const dir = t(state.direction === 'across' ? 'game.across' : 'game.down')
|
||||||
const { row, col } = state.selectedCell
|
|
||||||
const letter = state.entries[row]?.[col] || ''
|
|
||||||
const cellContent = letter ? letter : 'empty'
|
|
||||||
|
|
||||||
if (v === 'brief') {
|
if (v === 'brief') {
|
||||||
narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
|
announce(t('a11y.clueBrief', { number: state.activeClue, dir, text: clue.text }))
|
||||||
} else if (v === 'normal') {
|
} else if (v === 'normal') {
|
||||||
narrator.speak(`Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`)
|
announce(t('a11y.clueNormal', { number: state.activeClue, dir, length: len, text: clue.text }))
|
||||||
} else {
|
} else {
|
||||||
const puzzle = state.puzzle
|
announce(t('a11y.clueVerbose', { number: state.activeClue, dir, length: len, filled, text: clue.text }))
|
||||||
let neighbors = ''
|
|
||||||
if (col > 0 && puzzle.cells[row][col - 1] !== 'black') {
|
|
||||||
const l = state.entries[row][col - 1]
|
|
||||||
neighbors += l ? `${l} to the left` : 'blank to the left'
|
|
||||||
}
|
|
||||||
if (row > 0 && puzzle.cells[row - 1]?.[col] !== 'black') {
|
|
||||||
const l = state.entries[row - 1]?.[col]
|
|
||||||
if (neighbors) neighbors += ', '
|
|
||||||
neighbors += l ? `${l} above` : 'blank above'
|
|
||||||
}
|
|
||||||
let msg = `Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`
|
|
||||||
if (neighbors) msg += `. Neighboring: ${neighbors}`
|
|
||||||
narrator.speak(msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevCell.current = state.selectedCell
|
}
|
||||||
|
}
|
||||||
|
prevClueKey.current = state.activeClue !== null ? `${state.activeClue}-${state.direction}` : null
|
||||||
|
|
||||||
// letter entry - detect new letter in the grid
|
// letter entry and deletion
|
||||||
if (prevEntries.current && state.entries !== prevEntries.current) {
|
if (prevEntries.current && state.entries !== prevEntries.current) {
|
||||||
for (let r = 0; r < state.entries.length; r++) {
|
for (let r = 0; r < state.entries.length; r++) {
|
||||||
for (let c = 0; c < state.entries[r].length; c++) {
|
for (let c = 0; c < state.entries[r].length; c++) {
|
||||||
const cur = state.entries[r][c]
|
const cur = state.entries[r][c]
|
||||||
const old = prevEntries.current[r]?.[c] || ''
|
const old = prevEntries.current[r]?.[c] || ''
|
||||||
if (cur && !old) {
|
if (cur && !old) {
|
||||||
if (v === 'brief') narrator.speak(cur, false)
|
if (v === 'brief') announce(cur)
|
||||||
else if (v === 'normal') narrator.speak(`Entered ${cur}`, false)
|
else if (v === 'normal') announce(t('a11y.entered', { letter: cur }))
|
||||||
else narrator.speak(`Entered ${cur} at Row ${r + 1}, Column ${c + 1}`, false)
|
else announce(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }))
|
||||||
|
|
||||||
// check word completion
|
// word completion
|
||||||
const span = state.wordSpans.find(s =>
|
const span = state.wordSpans.find((s) =>
|
||||||
s.cells.some(([sr, sc]) => sr === r && sc === c)
|
s.cells.some(([sr, sc]) => sr === r && sc === c),
|
||||||
)
|
)
|
||||||
if (span) {
|
if (span) {
|
||||||
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
|
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
|
||||||
@@ -88,58 +67,54 @@ export function useNarrator() {
|
|||||||
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
|
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
|
||||||
if (!wasFilled) {
|
if (!wasFilled) {
|
||||||
const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('')
|
const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('')
|
||||||
setTimeout(() => narrator.speak(`Word complete: ${word.split('').join(' ')}`, false), 300)
|
setTimeout(() => announce(t('a11y.wordComplete', { word: word.split('').join(' ') })), 300)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!cur && old) {
|
||||||
|
if (v !== 'brief') announce(t('a11y.deleted'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevEntries.current = state.entries
|
prevEntries.current = state.entries
|
||||||
|
|
||||||
// clue focus change
|
// correct / incorrect / revealed cell feedback
|
||||||
if (state.activeClue !== null && state.activeClue !== prevActiveClue.current) {
|
if (prev.cellStatus !== state.cellStatus) {
|
||||||
const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
|
let correct = 0
|
||||||
const clue = clues.find(c => c.number === state.activeClue)
|
let incorrect = 0
|
||||||
if (clue) {
|
const revealed: [number, number][] = []
|
||||||
const span = state.wordSpans.find(s => s.number === state.activeClue && s.direction === state.direction)
|
|
||||||
const len = span?.cells.length || 0
|
|
||||||
const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
|
|
||||||
|
|
||||||
if (v === 'brief') {
|
|
||||||
narrator.speak(`${state.activeClue} ${state.direction}: ${clue.text}`, false)
|
|
||||||
} else if (v === 'normal') {
|
|
||||||
narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters: ${clue.text}`, false)
|
|
||||||
} else {
|
|
||||||
narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters, ${filled} filled: ${clue.text}`, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevActiveClue.current = state.activeClue
|
|
||||||
|
|
||||||
// hint reveal - detect newly revealed cells
|
|
||||||
if (prev.cellStatus !== state.cellStatus && prevEntries.current) {
|
|
||||||
for (let r = 0; r < state.cellStatus.length; r++) {
|
for (let r = 0; r < state.cellStatus.length; r++) {
|
||||||
for (let c = 0; c < state.cellStatus[r].length; c++) {
|
for (let c = 0; c < state.cellStatus[r].length; c++) {
|
||||||
if (state.cellStatus[r][c] === 'revealed' && prev.cellStatus[r]?.[c] !== 'revealed') {
|
const ns = state.cellStatus[r][c]
|
||||||
|
const ps = prev.cellStatus[r]?.[c]
|
||||||
|
if (ns === ps) continue
|
||||||
|
if (ns === 'correct') correct++
|
||||||
|
else if (ns === 'incorrect') incorrect++
|
||||||
|
else if (ns === 'revealed') revealed.push([r, c])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incorrect > 0) {
|
||||||
|
announce(incorrect === 1 ? t('a11y.incorrect') : t('a11y.incorrectMany', { count: incorrect }))
|
||||||
|
} else if (correct > 0) {
|
||||||
|
announce(correct === 1 ? t('a11y.correct') : t('a11y.correctMany', { count: correct }))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [r, c] of revealed) {
|
||||||
const letter = state.entries[r][c]
|
const letter = state.entries[r][c]
|
||||||
const span = state.wordSpans.find(s =>
|
const span = state.wordSpans.find((s) =>
|
||||||
s.cells.some(([sr, sc]) => sr === r && sc === c)
|
s.cells.some(([sr, sc]) => sr === r && sc === c),
|
||||||
)
|
)
|
||||||
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
|
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
|
||||||
if (letter && pos) {
|
if (letter && pos) announce(t('a11y.revealed', { letter, pos }))
|
||||||
narrator.speak(`Revealed letter ${letter} at position ${pos}`, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// puzzle solved
|
// puzzle solved
|
||||||
if (state.solved && !prevSolved.current) {
|
if (state.solved && !prevSolved.current) {
|
||||||
const time = formatTime(state.elapsed)
|
const time = formatTime(state.elapsed, t)
|
||||||
narrator.speak(`Puzzle complete. Time: ${time}`)
|
announce(t('a11y.solvedTime', { time }))
|
||||||
}
|
}
|
||||||
prevSolved.current = state.solved
|
prevSolved.current = state.solved
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
// stylus handling is now built into InkOverlay.tsx directly
|
|
||||||
// this file kept for import compatibility
|
|
||||||
export function useStylus(
|
|
||||||
_canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
|
||||||
_svgRef: React.RefObject<SVGSVGElement | null>,
|
|
||||||
) {}
|
|
||||||
+100
-36
@@ -13,58 +13,62 @@
|
|||||||
--text-secondary: #454545;
|
--text-secondary: #454545;
|
||||||
--text-tertiary: #4a4a4a;
|
--text-tertiary: #4a4a4a;
|
||||||
--border: #e0dcd8;
|
--border: #e0dcd8;
|
||||||
--border-light: #c5c0ba;
|
--border-light: #8f8b85;
|
||||||
--accent: #d4432f;
|
--accent: #9c2418;
|
||||||
--accent-hover: #ba3825;
|
--accent-hover: #7e1d13;
|
||||||
--accent-soft: rgba(212, 67, 47, 0.06);
|
--accent-soft: rgba(156, 36, 24, 0.07);
|
||||||
|
--btn-primary-text: #ffffff;
|
||||||
--cell-white: #ffffff;
|
--cell-white: #ffffff;
|
||||||
--cell-black: #0d0d0d;
|
--cell-black: #0d0d0d;
|
||||||
--cell-selected: #f5c888;
|
--cell-selected: #f5c888;
|
||||||
--cell-active: #f0d8a8;
|
--cell-active: #f0d8a8;
|
||||||
--cell-border: #8a8480;
|
--cell-border: #8a8480;
|
||||||
--correct: #2d7a4f;
|
--correct: #18603a;
|
||||||
--incorrect: #c93030;
|
--incorrect: #a90f1c;
|
||||||
--revealed: #3570a8;
|
--revealed: #1d5286;
|
||||||
--ink-color: #3a2820;
|
--ink-color: #3a2820;
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.06);
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.06);
|
||||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.08);
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.08);
|
||||||
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
--color-error: #ef4444;
|
--color-error: #a32020;
|
||||||
--color-success: #22c55e;
|
--color-success: #0f612f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #111111; --bg-alt: #191919; --surface: #1a1a1a; --surface-raised: #222;
|
--bg: #111111; --bg-alt: #191919; --surface: #1a1a1a; --surface-raised: #222;
|
||||||
--text: #ededed; --text-secondary: #b5b5b5; --text-tertiary: #ababab;
|
--text: #ededed; --text-secondary: #b5b5b5; --text-tertiary: #ababab;
|
||||||
--border: #2a2a2a; --border-light: #222;
|
--border: #6b6b6b; --border-light: #8a8a8a;
|
||||||
--accent: #e8594a; --accent-hover: #d04a3c; --accent-soft: rgba(232, 89, 74, 0.1);
|
--accent: #ee8a82; --accent-hover: #f5a49d; --accent-soft: rgba(238, 138, 130, 0.12); --btn-primary-text: #161313;
|
||||||
--cell-white: #1e1e1e; --cell-black: #090909;
|
--cell-white: #1e1e1e; --cell-black: #090909;
|
||||||
--cell-selected: #5a3818; --cell-active: #4a3020; --cell-border: #555555;
|
--cell-selected: #8a6a33; --cell-active: #7c6a3a; --cell-border: #7a7a7a;
|
||||||
--correct: #4cb87a; --incorrect: #e05050; --revealed: #5a9ad8; --ink-color: #d0b090;
|
--correct: #4cb87a; --incorrect: #ed9f9f; --revealed: #7bb4e8; --ink-color: #d0b090;
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2); --shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2); --shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.4);
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.4);
|
||||||
--color-error: #f87171;
|
--color-error: #f4a3a3;
|
||||||
--color-success: #4ade80;
|
--color-success: #4ade80;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg: #111111; --bg-alt: #191919; --surface: #1a1a1a; --surface-raised: #222;
|
--bg: #111111; --bg-alt: #191919; --surface: #1a1a1a; --surface-raised: #222;
|
||||||
--text: #ededed; --text-secondary: #b5b5b5; --text-tertiary: #ababab;
|
--text: #ededed; --text-secondary: #b5b5b5; --text-tertiary: #ababab;
|
||||||
--border: #2a2a2a; --border-light: #222;
|
--border: #6b6b6b; --border-light: #8a8a8a;
|
||||||
--accent: #e8594a; --accent-hover: #d04a3c; --accent-soft: rgba(232, 89, 74, 0.1);
|
--accent: #ee8a82; --accent-hover: #f5a49d; --accent-soft: rgba(238, 138, 130, 0.12); --btn-primary-text: #161313;
|
||||||
--cell-white: #1e1e1e; --cell-black: #090909;
|
--cell-white: #1e1e1e; --cell-black: #090909;
|
||||||
--cell-selected: #5a3818; --cell-active: #4a3020; --cell-border: #555555;
|
--cell-selected: #8a6a33; --cell-active: #7c6a3a; --cell-border: #7a7a7a;
|
||||||
--correct: #4cb87a; --incorrect: #e05050; --revealed: #5a9ad8; --ink-color: #d0b090;
|
--correct: #4cb87a; --incorrect: #ed9f9f; --revealed: #7bb4e8; --ink-color: #d0b090;
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2); --shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2); --shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.4);
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.4);
|
||||||
--color-error: #f87171;
|
--color-error: #f4a3a3;
|
||||||
--color-success: #4ade80;
|
--color-success: #4ade80;
|
||||||
}
|
}
|
||||||
[data-colorblind="protanopia"] { --correct: #3b82f6; --incorrect: #f97316; }
|
[data-colorblind="protanopia"] { --correct: #1d6fe0; --incorrect: #b45309; }
|
||||||
[data-colorblind="deuteranopia"] { --correct: #3b82f6; --incorrect: #f97316; }
|
:root[data-theme="dark"][data-colorblind="protanopia"] { --correct: #7db4f0; --incorrect: #f5b078; }
|
||||||
[data-colorblind="tritanopia"] { --correct: #ef4444; --incorrect: #06b6d4; }
|
[data-colorblind="deuteranopia"] { --correct: #1d6fe0; --incorrect: #b45309; }
|
||||||
|
:root[data-theme="dark"][data-colorblind="deuteranopia"] { --correct: #7db4f0; --incorrect: #f5b078; }
|
||||||
|
[data-colorblind="tritanopia"] { --correct: #dc2626; --incorrect: #0b6a82; }
|
||||||
|
:root[data-theme="dark"][data-colorblind="tritanopia"] { --correct: #f1a0a0; --incorrect: #74d0e8; }
|
||||||
|
|
||||||
.grid-sr-grid {
|
.grid-sr-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -82,6 +86,57 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-sr-cell:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 3px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-grid-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sr-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sr-cell {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sr-cell:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 3px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hidden typing field so touch devices can summon the on-screen keyboard */
|
||||||
|
.grid-type-input {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0.01;
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-input-mode {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute; width: 1px; height: 1px;
|
position: absolute; width: 1px; height: 1px;
|
||||||
padding: 0; margin: -1px; overflow: hidden;
|
padding: 0; margin: -1px; overflow: hidden;
|
||||||
@@ -163,7 +218,7 @@ body { font-family: var(--font-body); background: var(--bg); color: var(--text);
|
|||||||
.btn:active { transform: scale(0.97); }
|
.btn:active { transform: scale(0.97); }
|
||||||
.btn:disabled { opacity: 0.3; cursor: default; }
|
.btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
.btn-primary { background: var(--accent); color: var(--btn-primary-text); border-color: var(--accent); }
|
||||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
|
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
|
||||||
.btn-sm { font-size: 0.625rem; padding: 0.5rem 0.75rem; min-height: 2.75rem; min-width: 2.75rem; }
|
.btn-sm { font-size: 0.625rem; padding: 0.5rem 0.75rem; min-height: 2.75rem; min-width: 2.75rem; }
|
||||||
.btn-ghost { border-color: transparent; }
|
.btn-ghost { border-color: transparent; }
|
||||||
@@ -542,6 +597,20 @@ body { font-family: var(--font-body); background: var(--bg); color: var(--text);
|
|||||||
.grid-wrapper:focus-within {
|
.grid-wrapper:focus-within {
|
||||||
outline: 2px solid var(--accent); outline-offset: 2px;
|
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clue-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-nav-current {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
@keyframes gridReveal {
|
@keyframes gridReveal {
|
||||||
from { opacity: 0; transform: scale(0.96); }
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
@@ -1170,8 +1239,6 @@ input[type="range"] { accent-color: var(--accent); }
|
|||||||
color: var(--text-tertiary); margin-top: 0.125rem;
|
color: var(--text-tertiary); margin-top: 0.125rem;
|
||||||
}
|
}
|
||||||
.settings-narrator-controls { display: flex; flex-direction: column; gap: 0.5rem; }
|
.settings-narrator-controls { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.settings-narrator-detail { display: flex; flex-direction: column; gap: 0.75rem; padding-top: 0.5rem; }
|
|
||||||
.settings-narrator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
|
||||||
.settings-narrator-item { display: flex; flex-direction: column; gap: 0.25rem; }
|
.settings-narrator-item { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
.settings-label {
|
.settings-label {
|
||||||
display: block; font-family: var(--font-mono); font-size: 0.563rem; font-weight: 500;
|
display: block; font-family: var(--font-mono); font-size: 0.563rem; font-weight: 500;
|
||||||
@@ -2648,7 +2715,7 @@ svg text {
|
|||||||
.cv-select { position: relative; display: inline-block; }
|
.cv-select { position: relative; display: inline-block; }
|
||||||
.cv-select-trigger {
|
.cv-select-trigger {
|
||||||
display: flex; align-items: center; gap: 6px; justify-content: space-between;
|
display: flex; align-items: center; gap: 6px; justify-content: space-between;
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border-light);
|
||||||
color: var(--text); cursor: pointer; min-width: 6rem;
|
color: var(--text); cursor: pointer; min-width: 6rem;
|
||||||
font-family: var(--font-mono); font-size: 0.688rem;
|
font-family: var(--font-mono); font-size: 0.688rem;
|
||||||
padding: 0.375rem 0.625rem;
|
padding: 0.375rem 0.625rem;
|
||||||
@@ -2675,7 +2742,7 @@ svg text {
|
|||||||
|
|
||||||
.cv-number {
|
.cv-number {
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex; align-items: center;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
.cv-number-btn {
|
.cv-number-btn {
|
||||||
background: var(--surface); border: none; cursor: pointer;
|
background: var(--surface); border: none; cursor: pointer;
|
||||||
@@ -3358,15 +3425,6 @@ svg text {
|
|||||||
}
|
}
|
||||||
.header-room-tag:hover { border-color: var(--border); }
|
.header-room-tag:hover { border-color: var(--border); }
|
||||||
|
|
||||||
.header-narrator-tag {
|
|
||||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
|
||||||
font-family: var(--font-mono); font-size: 0.563rem;
|
|
||||||
letter-spacing: 0.06em; color: var(--accent);
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* welcome join room */
|
/* welcome join room */
|
||||||
.welcome-join-room {
|
.welcome-join-room {
|
||||||
display: flex; align-items: center; gap: 0.375rem;
|
display: flex; align-items: center; gap: 0.375rem;
|
||||||
@@ -3890,3 +3948,9 @@ svg text {
|
|||||||
.settings-body { flex-direction: column; }
|
.settings-body { flex-direction: column; }
|
||||||
.settings-sidebar { flex-direction: row; overflow-x: auto; }
|
.settings-sidebar { flex-direction: row; overflow-x: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* focus-visible contracts for composite controls */
|
||||||
|
.cv-number-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.cv-select-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.cv-select-option:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|||||||
@@ -79,6 +79,44 @@
|
|||||||
"autoCheckTooltip": "Auto-check - marks wrong letters as you type",
|
"autoCheckTooltip": "Auto-check - marks wrong letters as you type",
|
||||||
"shareResult": "Share result"
|
"shareResult": "Share result"
|
||||||
},
|
},
|
||||||
|
"a11y": {
|
||||||
|
"noCellSelected": "No cell selected",
|
||||||
|
"noClueSelected": "No clue selected",
|
||||||
|
"noPuzzleLoaded": "No puzzle loaded",
|
||||||
|
"noAcrossWord": "No across word here",
|
||||||
|
"noDownWord": "No down word here",
|
||||||
|
"position": ", position {{pos}} of {{len}}",
|
||||||
|
"wordReading": "{{dir}} {{number}}, {{length}} letters: {{letters}}",
|
||||||
|
"clueBrief": "{{number}} {{dir}}: {{text}}",
|
||||||
|
"clueNormal": "{{number}} {{dir}}, {{length}} letters: {{text}}",
|
||||||
|
"clueVerbose": "{{number}} {{dir}}, {{length}} letters, {{filled}} filled: {{text}}",
|
||||||
|
"empty": "empty",
|
||||||
|
"blank": "blank",
|
||||||
|
"wordPattern": "{{number}} {{dir}}, {{length}} letters: {{pattern}}. {{clue}}",
|
||||||
|
"clueNavigation": "Clue navigation",
|
||||||
|
"previousClue": "Previous clue",
|
||||||
|
"nextClue": "Next clue",
|
||||||
|
"nowSolving": "Now solving {{dir}}",
|
||||||
|
"entered": "Entered {{letter}}",
|
||||||
|
"enteredAt": "Entered {{letter}} at Row {{row}}, Column {{col}}",
|
||||||
|
"deleted": "Deleted",
|
||||||
|
"correct": "Correct",
|
||||||
|
"correctMany": "{{count}} correct",
|
||||||
|
"incorrect": "Incorrect",
|
||||||
|
"incorrectMany": "{{count}} incorrect",
|
||||||
|
"wordComplete": "Word complete: {{word}}",
|
||||||
|
"revealed": "Revealed letter {{letter}} at position {{pos}}",
|
||||||
|
"solvedTime": "Puzzle complete. Time: {{time}}",
|
||||||
|
"seconds_one": "{{count}} second",
|
||||||
|
"seconds_other": "{{count}} seconds",
|
||||||
|
"minutes_one": "{{count}} minute",
|
||||||
|
"minutes_other": "{{count}} minutes",
|
||||||
|
"overview": "{{width}} by {{height}} puzzle, {{filled}} of {{total}} cells filled, {{remaining}} clues remaining"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"increase": "Increase",
|
||||||
|
"decrease": "Decrease"
|
||||||
|
},
|
||||||
"grid": {
|
"grid": {
|
||||||
"ariaLabel": "Crossword puzzle, {{width}} by {{height}}",
|
"ariaLabel": "Crossword puzzle, {{width}} by {{height}}",
|
||||||
"blackCell": "Black cell",
|
"blackCell": "Black cell",
|
||||||
@@ -90,6 +128,8 @@
|
|||||||
"cellIncorrect": ", incorrect",
|
"cellIncorrect": ", incorrect",
|
||||||
"cellRevealed": ", revealed",
|
"cellRevealed": ", revealed",
|
||||||
"cellPencil": ", pencil letter {{letter}}",
|
"cellPencil": ", pencil letter {{letter}}",
|
||||||
|
"cellWord": ", {{number}} {{dir}}",
|
||||||
|
"cellClue": ", {{number}} {{dir}}: {{clue}}",
|
||||||
"handwritingCanvas": "Handwriting input area"
|
"handwritingCanvas": "Handwriting input area"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@@ -135,8 +175,6 @@
|
|||||||
"aboutInstance": "About this instance",
|
"aboutInstance": "About this instance",
|
||||||
"signInPasskey": "Sign in with passkey",
|
"signInPasskey": "Sign in with passkey",
|
||||||
"keyboardShortcuts": "Keyboard shortcuts",
|
"keyboardShortcuts": "Keyboard shortcuts",
|
||||||
"narrator": "Narrator",
|
|
||||||
"narratorActive": "Narrator active",
|
|
||||||
"newPuzzle": "New puzzle",
|
"newPuzzle": "New puzzle",
|
||||||
"startNewPuzzle": "Start a new puzzle",
|
"startNewPuzzle": "Start a new puzzle",
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
@@ -163,20 +201,19 @@
|
|||||||
"subtitle": "Available while the grid is focused",
|
"subtitle": "Available while the grid is focused",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"editing": "Editing",
|
"editing": "Editing",
|
||||||
"narrator": "Narrator mode",
|
|
||||||
"arrows": "Move between cells",
|
"arrows": "Move between cells",
|
||||||
"tabNext": "Jump to next clue",
|
"tabNext": "Jump to next clue",
|
||||||
"tabPrev": "Jump to previous clue",
|
"tabPrev": "Jump to previous clue",
|
||||||
"space": "Toggle across / down",
|
"space": "Toggle across / down",
|
||||||
"escape": "Deselect cell",
|
"escape": "Deselect cell",
|
||||||
|
"homeEnd": "Jump to start / end of row",
|
||||||
|
"reading": "Reading",
|
||||||
|
"readWord": "Read current clue and word",
|
||||||
"letters": "Enter a letter",
|
"letters": "Enter a letter",
|
||||||
"backspace": "Clear current cell",
|
"backspace": "Clear current cell",
|
||||||
"delete": "Clear and move back",
|
"delete": "Clear and move back",
|
||||||
"ctrlZ": "Undo last entry",
|
"ctrlZ": "Undo last entry",
|
||||||
"ctrlY": "Redo last entry",
|
"ctrlY": "Redo last entry"
|
||||||
"question": "Re-read current clue",
|
|
||||||
"ctrlH": "Puzzle overview",
|
|
||||||
"f1": "Puzzle overview"
|
|
||||||
},
|
},
|
||||||
"difficulty": {
|
"difficulty": {
|
||||||
"veryEasy": "Very easy",
|
"veryEasy": "Very easy",
|
||||||
@@ -537,23 +574,13 @@
|
|||||||
"pushComingSoon": "Coming soon"
|
"pushComingSoon": "Coming soon"
|
||||||
},
|
},
|
||||||
"narrator": {
|
"narrator": {
|
||||||
"title": "Narrator",
|
"title": "Screen reader",
|
||||||
"narratorMode": "Narrator mode",
|
"hint": "Announces cell positions, clues, and game events to screen readers and braille displays",
|
||||||
"hint": "Speaks cell positions, clues, and game events aloud",
|
|
||||||
"speechRate": "Speech rate: {{rate}}x",
|
|
||||||
"pitch": "Pitch: {{pitch}}x",
|
|
||||||
"voice": "Voice",
|
|
||||||
"defaultVoice": "Default",
|
|
||||||
"verbosity": "Verbosity",
|
"verbosity": "Verbosity",
|
||||||
"brief": "Brief",
|
"brief": "Brief",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"verbose": "Verbose",
|
"verbose": "Verbose",
|
||||||
"output": "Output",
|
"soundEffects": "Sound effects"
|
||||||
"outputVoice": "Voice",
|
|
||||||
"outputScreenReader": "Screen reader",
|
|
||||||
"outputBoth": "Both",
|
|
||||||
"soundEffects": "Sound effects",
|
|
||||||
"notSupported": "Your browser does not support speech synthesis"
|
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"duration": "Session duration",
|
"duration": "Session duration",
|
||||||
@@ -672,6 +699,11 @@
|
|||||||
"rotationalSymmetry": "Rotational symmetry",
|
"rotationalSymmetry": "Rotational symmetry",
|
||||||
"mirrorSymmetry": "Mirror symmetry",
|
"mirrorSymmetry": "Mirror symmetry",
|
||||||
"noSymmetry": "No symmetry",
|
"noSymmetry": "No symmetry",
|
||||||
|
"symmetry": "Symmetry",
|
||||||
|
"width": "Width",
|
||||||
|
"height": "Height",
|
||||||
|
"delete": "Delete",
|
||||||
|
"offensivePattern": "That grid pattern is not allowed",
|
||||||
"checkAnswers": "Check answers",
|
"checkAnswers": "Check answers",
|
||||||
"cluesClickToEdit": "Clues (click to edit)",
|
"cluesClickToEdit": "Clues (click to edit)",
|
||||||
"writeClue": "Write a clue...",
|
"writeClue": "Write a clue...",
|
||||||
@@ -928,7 +960,7 @@
|
|||||||
"activityPubDomain": "your public domain",
|
"activityPubDomain": "your public domain",
|
||||||
"activityPubActor": "optional, defaults to \"cruciverb\"",
|
"activityPubActor": "optional, defaults to \"cruciverb\"",
|
||||||
"activityPubReady": "Database tables are ready. RSA keys generate on first startup.",
|
"activityPubReady": "Database tables are ready. RSA keys generate on first startup.",
|
||||||
"apHumor": "AP post humor",
|
"apHumor": "ActivityPub post humor",
|
||||||
"apHumorDesc": "Custom taglines, sign-offs, and community middles added to daily ActivityPub posts. One entry per line.",
|
"apHumorDesc": "Custom taglines, sign-offs, and community middles added to daily ActivityPub posts. One entry per line.",
|
||||||
"dailyTaglines": "Daily taglines",
|
"dailyTaglines": "Daily taglines",
|
||||||
"dailySignoffs": "Daily sign-offs",
|
"dailySignoffs": "Daily sign-offs",
|
||||||
|
|||||||
+12
-73
@@ -1,98 +1,37 @@
|
|||||||
export interface NarratorSettings {
|
export interface NarratorSettings {
|
||||||
enabled: boolean
|
|
||||||
rate: number
|
|
||||||
pitch: number
|
|
||||||
voice: string
|
|
||||||
verbosity: 'brief' | 'normal' | 'verbose'
|
verbosity: 'brief' | 'normal' | 'verbose'
|
||||||
mode: 'voice' | 'screenreader' | 'both'
|
|
||||||
sounds: boolean
|
sounds: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultNarratorSettings: NarratorSettings = {
|
export const defaultNarratorSettings: NarratorSettings = {
|
||||||
enabled: false,
|
|
||||||
rate: 1.0,
|
|
||||||
pitch: 1.0,
|
|
||||||
voice: '',
|
|
||||||
verbosity: 'normal',
|
verbosity: 'normal',
|
||||||
mode: 'voice',
|
|
||||||
sounds: true,
|
sounds: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings: NarratorSettings = { ...defaultNarratorSettings }
|
// A polite live region that the screen reader (speech and braille) announces
|
||||||
|
// from. There is no self-voicing here: announcing through the accessibility
|
||||||
export function updateSettings(s: NarratorSettings) {
|
// tree lets the user's own screen reader and braille display do the reading.
|
||||||
settings = s
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSupported(): boolean {
|
|
||||||
return 'speechSynthesis' in window
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getVoices(): SpeechSynthesisVoice[] {
|
|
||||||
if (!isSupported()) return []
|
|
||||||
return window.speechSynthesis.getVoices()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cancel() {
|
|
||||||
if (!isSupported()) return
|
|
||||||
window.speechSynthesis.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// a polite live region that screen readers announce from, created on demand
|
|
||||||
let srRegion: HTMLElement | null = null
|
let srRegion: HTMLElement | null = null
|
||||||
|
let regionTimer: number | undefined
|
||||||
|
|
||||||
function getSrRegion(): HTMLElement {
|
function getSrRegion(): HTMLElement {
|
||||||
if (!srRegion) {
|
if (!srRegion) {
|
||||||
srRegion = document.createElement('div')
|
srRegion = document.createElement('div')
|
||||||
srRegion.setAttribute('aria-live', 'polite')
|
srRegion.setAttribute('aria-live', 'polite')
|
||||||
srRegion.setAttribute('role', 'status')
|
srRegion.setAttribute('role', 'status')
|
||||||
|
srRegion.setAttribute('aria-atomic', 'true')
|
||||||
srRegion.className = 'visually-hidden'
|
srRegion.className = 'visually-hidden'
|
||||||
document.body.appendChild(srRegion)
|
document.body.appendChild(srRegion)
|
||||||
}
|
}
|
||||||
return srRegion
|
return srRegion
|
||||||
}
|
}
|
||||||
|
|
||||||
function announceToScreenReader(text: string) {
|
export function announce(text: string) {
|
||||||
const region = getSrRegion()
|
const region = getSrRegion()
|
||||||
const msg = document.createElement('span')
|
window.clearTimeout(regionTimer)
|
||||||
msg.textContent = `${text} `
|
// Clear then set in a later task so repeated identical text still re-announces.
|
||||||
region.appendChild(msg)
|
region.textContent = ''
|
||||||
while (region.children.length > 6) {
|
regionTimer = window.setTimeout(() => {
|
||||||
const first = region.firstChild
|
region.textContent = text
|
||||||
if (first) region.removeChild(first)
|
}, 0)
|
||||||
}
|
|
||||||
setTimeout(() => msg.remove(), 15000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function speak(text: string, interrupt = true) {
|
|
||||||
if (!settings.enabled) return
|
|
||||||
|
|
||||||
const mode = settings.mode
|
|
||||||
const wantsVoice = (mode === 'voice' || mode === 'both') && isSupported()
|
|
||||||
const wantsSr = mode === 'screenreader' || mode === 'both' || !isSupported()
|
|
||||||
|
|
||||||
if (wantsSr) announceToScreenReader(text)
|
|
||||||
if (!wantsVoice) return
|
|
||||||
|
|
||||||
if (interrupt) cancel()
|
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(text)
|
|
||||||
utterance.rate = settings.rate
|
|
||||||
utterance.pitch = settings.pitch
|
|
||||||
|
|
||||||
if (settings.voice) {
|
|
||||||
const voices = getVoices()
|
|
||||||
const match = voices.find(v => v.name === settings.voice)
|
|
||||||
if (match) utterance.voice = match
|
|
||||||
}
|
|
||||||
|
|
||||||
window.speechSynthesis.speak(utterance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// voices load async - call this early so they're ready
|
|
||||||
export function preloadVoices() {
|
|
||||||
if (!isSupported()) return
|
|
||||||
getVoices()
|
|
||||||
}
|
|
||||||
|
|
||||||
preloadVoices()
|
|
||||||
+22
-9
@@ -138,9 +138,7 @@ interface PuzzleStore {
|
|||||||
pencilEntries: string[][]
|
pencilEntries: string[][]
|
||||||
togglePencilMode: () => void
|
togglePencilMode: () => void
|
||||||
|
|
||||||
narratorEnabled: boolean
|
|
||||||
narratorSettings: NarratorSettings
|
narratorSettings: NarratorSettings
|
||||||
setNarratorEnabled: (enabled: boolean) => void
|
|
||||||
setNarratorSettings: (settings: Partial<NarratorSettings>) => void
|
setNarratorSettings: (settings: Partial<NarratorSettings>) => void
|
||||||
|
|
||||||
partySubmitAnswer: (answer: string) => void
|
partySubmitAnswer: (answer: string) => void
|
||||||
@@ -168,6 +166,7 @@ interface PuzzleStore {
|
|||||||
typeLetter: (letter: string) => void
|
typeLetter: (letter: string) => void
|
||||||
deleteLetter: () => void
|
deleteLetter: () => void
|
||||||
moveSelection: (dr: number, dc: number) => void
|
moveSelection: (dr: number, dc: number) => void
|
||||||
|
moveRowEdge: (home: boolean) => void
|
||||||
tabClue: (forward: boolean) => void
|
tabClue: (forward: boolean) => void
|
||||||
deselectCell: () => void
|
deselectCell: () => void
|
||||||
validateSolution: () => Promise<void>
|
validateSolution: () => Promise<void>
|
||||||
@@ -638,16 +637,10 @@ export const useStore = create<PuzzleStore>((set, get) => ({
|
|||||||
pencilMode: false,
|
pencilMode: false,
|
||||||
pencilEntries: [],
|
pencilEntries: [],
|
||||||
|
|
||||||
narratorEnabled: false,
|
|
||||||
narratorSettings: { ...defaultNarratorSettings },
|
narratorSettings: { ...defaultNarratorSettings },
|
||||||
|
|
||||||
setNarratorEnabled: (enabled: boolean) => {
|
|
||||||
set({ narratorEnabled: enabled, narratorSettings: { ...get().narratorSettings, enabled } })
|
|
||||||
},
|
|
||||||
|
|
||||||
setNarratorSettings: (partial: Partial<NarratorSettings>) => {
|
setNarratorSettings: (partial: Partial<NarratorSettings>) => {
|
||||||
const next = { ...get().narratorSettings, ...partial }
|
set({ narratorSettings: { ...get().narratorSettings, ...partial } })
|
||||||
set({ narratorSettings: next, narratorEnabled: next.enabled })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
undo: () => {
|
undo: () => {
|
||||||
@@ -1335,6 +1328,26 @@ export const useStore = create<PuzzleStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
moveRowEdge: (home) => {
|
||||||
|
const { selectedCell, puzzle, wordSpans, direction } = get()
|
||||||
|
if (!selectedCell || !puzzle) return
|
||||||
|
const row = selectedCell.row
|
||||||
|
const width = puzzle.width
|
||||||
|
let col: number | null = null
|
||||||
|
if (home) {
|
||||||
|
for (let c = 0; c < width; c++) {
|
||||||
|
if (puzzle.cells[row][c] !== 'black') { col = c; break }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let c = width - 1; c >= 0; c--) {
|
||||||
|
if (puzzle.cells[row][c] !== 'black') { col = c; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (col === null) return
|
||||||
|
const span = findClueForCell(wordSpans, row, col, direction)
|
||||||
|
set({ selectedCell: { row, col }, activeClue: span?.number ?? get().activeClue })
|
||||||
|
},
|
||||||
|
|
||||||
tabClue: (forward) => {
|
tabClue: (forward) => {
|
||||||
const { wordSpans, direction, activeClue, puzzle } = get()
|
const { wordSpans, direction, activeClue, puzzle } = get()
|
||||||
if (!puzzle) return
|
if (!puzzle) return
|
||||||
|
|||||||
Reference in New Issue
Block a user