Compare commits
5 Commits
db54882933
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b425e7f3b6 | |||
| a9abf582f5 | |||
| 4dc4d615e5 | |||
| aa70c61abd | |||
| 093b27f3f4 |
@@ -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));
|
||||
|
||||
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 {
|
||||
@@ -130,100 +130,16 @@ fn esc(s: &str) -> 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> {
|
||||
// collect all objects first, then serialize
|
||||
|
||||
@@ -241,6 +241,16 @@ pub fn generate_themed_puzzle(
|
||||
) -> Result<Puzzle> {
|
||||
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()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
@@ -250,7 +260,7 @@ pub fn generate_themed_puzzle(
|
||||
if cores > 1 && config.width > 9 {
|
||||
return parallel_generate(
|
||||
config,
|
||||
dict,
|
||||
solve_dict,
|
||||
clue_db,
|
||||
difficulty,
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
// one is a prefix of the other (SEAL / SEALS, RUN / RUNNING)
|
||||
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
|
||||
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),
|
||||
).unwrap_or(100.0);
|
||||
if reputation < 30.0 {
|
||||
|
||||
@@ -252,10 +252,12 @@ pub async fn admin_setup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, Response> {
|
||||
let dbi = state.db.lock().await;
|
||||
|
||||
if db::has_admin_account(&dbi.db) {
|
||||
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
|
||||
// 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 let Ok(key) = std::env::var("ADMIN_SETUP_KEY") {
|
||||
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("");
|
||||
@@ -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);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
@@ -388,7 +403,7 @@ pub async fn stats(
|
||||
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)
|
||||
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
|
||||
GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10"
|
||||
) {
|
||||
@@ -468,9 +483,9 @@ pub async fn list_sessions(
|
||||
check_admin(&headers).await?;
|
||||
let dbi = state.db.lock().await;
|
||||
let mut stmt = dbi.db.prepare(
|
||||
"SELECT s.token, 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 current_streak FROM streaks st WHERE st.session_token = s.token),
|
||||
"SELECT s.account_id, s.display_name, s.created_at, s.last_seen,
|
||||
(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.account_id),
|
||||
COALESCE(s.banned, 0)
|
||||
FROM sessions s ORDER BY s.last_seen DESC LIMIT 100"
|
||||
).unwrap();
|
||||
@@ -495,7 +510,7 @@ pub async fn delete_session(
|
||||
) -> Result<Json<serde_json::Value>, Response> {
|
||||
check_admin(&headers).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();
|
||||
db::log_admin_action(&dbi.db, "delete_session", Some(&token), None);
|
||||
invalidate_stats_cache();
|
||||
@@ -1395,7 +1410,7 @@ pub async fn list_community_puzzles(
|
||||
let dbi = state.db.lock().await;
|
||||
let mut stmt = dbi.db.prepare(
|
||||
"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"
|
||||
).unwrap();
|
||||
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 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,
|
||||
&crate::security::sanitize_html(name),
|
||||
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 (current, best, last_date) = db::get_streak(&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!({
|
||||
"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", [])
|
||||
.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
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub fn is_banned(db: &Connection, token: &str) -> bool {
|
||||
db.query_row(
|
||||
"SELECT banned FROM sessions WHERE token = ?1",
|
||||
"SELECT COUNT(*) FROM sessions WHERE account_id = ?1 AND banned = 1",
|
||||
[token],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
@@ -557,7 +608,7 @@ pub fn cleanup_user_content(db: &Connection, token: &str) {
|
||||
.ok();
|
||||
// clear display name so deleted/banned users can't be looked up
|
||||
db.execute(
|
||||
"UPDATE sessions SET display_name = NULL WHERE token = ?1",
|
||||
"UPDATE sessions SET display_name = NULL WHERE account_id = ?1",
|
||||
[token],
|
||||
)
|
||||
.ok();
|
||||
@@ -845,30 +896,79 @@ pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String
|
||||
}
|
||||
|
||||
// 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>) {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let key = token_hash(token);
|
||||
|
||||
let existing = db.query_row(
|
||||
"SELECT token, display_name FROM sessions WHERE token = ?1",
|
||||
[token],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||
"SELECT account_id, display_name FROM sessions WHERE token = ?1",
|
||||
[&key],
|
||||
|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(
|
||||
"UPDATE sessions SET last_seen = ?1 WHERE token = ?2",
|
||||
params![now, token],
|
||||
params![now, &key],
|
||||
)
|
||||
.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(
|
||||
"INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)",
|
||||
params![token, now],
|
||||
"INSERT INTO sessions (token, account_id, created_at, last_seen) VALUES (?1, ?2, ?3, ?3)",
|
||||
params![&key, &account_id, now],
|
||||
)
|
||||
.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> {
|
||||
@@ -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) {
|
||||
db.execute(
|
||||
"UPDATE sessions SET display_name = ?1 WHERE token = ?2",
|
||||
"UPDATE sessions SET display_name = ?1 WHERE account_id = ?2",
|
||||
params![name, token],
|
||||
)
|
||||
.ok();
|
||||
@@ -1123,8 +1223,13 @@ pub fn get_community_clues(
|
||||
}
|
||||
|
||||
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
|
||||
let existing: Option<i32> = db
|
||||
let existing: Option<i32> = tx
|
||||
.query_row(
|
||||
"SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2",
|
||||
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 old_vote == vote {
|
||||
return false;
|
||||
} // same vote, no change
|
||||
return false; // same vote, no change; tx rolls back on drop
|
||||
}
|
||||
// undo old vote
|
||||
if old_vote > 0 {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
} else {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
}
|
||||
// apply new vote
|
||||
if vote > 0 {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
} else {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
}
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3",
|
||||
params![vote, token, clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
} else {
|
||||
// new vote
|
||||
if vote > 0 {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
} else {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
}
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)",
|
||||
params![token, clue_id, vote],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
"SELECT upvotes, downvotes FROM community_clues WHERE id = ?1",
|
||||
[clue_id],
|
||||
@@ -1201,20 +1298,18 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
if up - down >= 3 {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
} else if down - up >= 3 {
|
||||
db.execute(
|
||||
let _ = tx.execute(
|
||||
"UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
|
||||
[clue_id],
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
tx.commit().is_ok()
|
||||
}
|
||||
|
||||
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(
|
||||
"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
|
||||
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
|
||||
WHERE up.status = 'published' AND up.clues_json IS NOT NULL
|
||||
ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2"
|
||||
@@ -2025,23 +2120,9 @@ pub fn import_user_data(
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
// streak (only if better than current)
|
||||
if let Some(streak) = data.get("streak") {
|
||||
let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Ignore imported streak values: a client-supplied streak is untrusted and
|
||||
// would let anyone inflate the streak leaderboard. Streaks are re-derived
|
||||
// from actual solves by update_streak.
|
||||
|
||||
// achievements (merge - only add ones not already unlocked)
|
||||
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],
|
||||
)
|
||||
.ok();
|
||||
db.execute("DELETE FROM sessions WHERE token = ?1", [token])
|
||||
db.execute("DELETE FROM sessions WHERE account_id = ?1", [token])
|
||||
.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 {
|
||||
let session: serde_json::Value = db
|
||||
.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],
|
||||
|row| {
|
||||
Ok(serde_json::json!({
|
||||
@@ -2295,7 +2376,7 @@ pub fn get_leaderboard_filtered(
|
||||
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
|
||||
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
|
||||
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)
|
||||
@@ -2325,7 +2406,7 @@ pub fn get_daily_leaderboard_filtered(
|
||||
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
|
||||
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 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
|
||||
@@ -2405,8 +2486,8 @@ pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 {
|
||||
let sessions: Vec<(String, String, String)> = {
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT s.token, st.settings_json, s.last_seen FROM sessions s
|
||||
JOIN settings st ON st.session_token = s.token",
|
||||
"SELECT s.account_id, st.settings_json, s.last_seen FROM sessions s
|
||||
JOIN settings st ON st.session_token = s.account_id",
|
||||
)
|
||||
.unwrap();
|
||||
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,
|
||||
p.daily_date
|
||||
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 settings st ON sr.session_token = st.session_token
|
||||
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(
|
||||
"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
|
||||
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 settings st ON sr.session_token = st.session_token
|
||||
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(
|
||||
"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
|
||||
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
|
||||
WHERE st2.current_streak > 0
|
||||
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(
|
||||
"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
|
||||
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
|
||||
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)
|
||||
@@ -2857,7 +2938,7 @@ pub fn get_clean_leaderboard(
|
||||
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
|
||||
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 settings st ON sr.session_token = st.session_token
|
||||
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 {
|
||||
"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
|
||||
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 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
|
||||
@@ -3516,7 +3597,7 @@ pub fn get_daily_leaderboard_by_date(
|
||||
} 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
|
||||
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 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
|
||||
|
||||
@@ -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_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")])
|
||||
} else {
|
||||
tracing::warn!("CORS_ORIGIN not set - using permissive CORS (set CORS_ORIGIN for production)");
|
||||
CorsLayer::permissive()
|
||||
tracing::warn!("CORS_ORIGIN not set - allowing same-origin requests only (set CORS_ORIGIN to allow cross-origin)");
|
||||
CorsLayer::new()
|
||||
};
|
||||
|
||||
let app = app
|
||||
|
||||
@@ -961,7 +961,7 @@ pub async fn create_room(
|
||||
RoomMode::Party => 20,
|
||||
};
|
||||
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
let (tx, _) = broadcast::channel(1024);
|
||||
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");
|
||||
@@ -1374,6 +1374,31 @@ async fn handle_ws(
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (token, name) = db::get_or_create_session(&dbi.db, &session_token);
|
||||
let settings_json = db::get_settings(&dbi.db, &token);
|
||||
let new_plain = crate::session::generate_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)
|
||||
.ok()
|
||||
.and_then(|v| v.get("cookieLifetime")?.as_u64())
|
||||
.unwrap_or(2_592_000);
|
||||
|
||||
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!({
|
||||
@@ -2740,7 +2745,7 @@ pub async fn get_session_info(
|
||||
) -> Result<Response, AppError> {
|
||||
let dbi = state.db.lock().await;
|
||||
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],
|
||||
|row| Ok(serde_json::json!({
|
||||
"created_at": row.get::<_, String>(0)?,
|
||||
@@ -3446,7 +3451,7 @@ pub async fn create_pack(
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
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(
|
||||
&dbi.db, &id,
|
||||
&crate::security::sanitize_html(name),
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn is_internal_url(url: &str) -> bool {
|
||||
if host.contains('@') { return true; }
|
||||
|
||||
// loopback
|
||||
host == "localhost"
|
||||
let blocked = host == "localhost"
|
||||
|| host.starts_with("127.") // full 127.0.0.0/8 range
|
||||
|| host == "::1"
|
||||
|| host.starts_with("::ffff:127.") // IPv4-mapped IPv6 loopback
|
||||
@@ -146,7 +146,8 @@ pub fn is_internal_url(url: &str) -> bool {
|
||||
// cloud metadata endpoints
|
||||
|| host == "169.254.169.254"
|
||||
|| 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 {
|
||||
@@ -163,6 +164,47 @@ fn is_cgnat(host: &str) -> bool {
|
||||
(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 {
|
||||
input
|
||||
.replace('&', "&")
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::state::AppState;
|
||||
|
||||
pub struct Session {
|
||||
pub token: String,
|
||||
pub cookie_token: String,
|
||||
pub display_name: Option<String>,
|
||||
pub is_new: bool,
|
||||
pub cookie_max_age: u64,
|
||||
@@ -16,16 +17,16 @@ pub struct Session {
|
||||
|
||||
impl Session {
|
||||
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 secure_flag = if secure { "; Secure" } else { "" };
|
||||
|
||||
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 {
|
||||
format!(
|
||||
"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,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let token = parts
|
||||
let cookie_token = parts
|
||||
.headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -57,15 +58,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
|
||||
.unwrap_or_default();
|
||||
|
||||
let dbi = state.db.lock().await;
|
||||
let (mut token, mut name, mut is_new) = match token {
|
||||
Some(t) if !t.is_empty() => {
|
||||
let (token, name) = crate::db::get_or_create_session(&dbi.db, &t);
|
||||
(token, name, false)
|
||||
let (mut cookie_token, mut token, mut name, mut is_new) = match cookie_token {
|
||||
Some(t) if is_valid_token(&t) => {
|
||||
let (acct, name) = crate::db::get_or_create_session(&dbi.db, &t);
|
||||
(t, acct, name, false)
|
||||
}
|
||||
_ => {
|
||||
let new_token = generate_token();
|
||||
let (token, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
|
||||
(token, name, true)
|
||||
let (acct, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
|
||||
(new_token, acct, name, true)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,14 +83,20 @@ impl FromRequestParts<Arc<AppState>> for Session {
|
||||
.unwrap_or(false)
|
||||
&& crate::db::has_passkey(&dbi.db, &token);
|
||||
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 {
|
||||
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 => {
|
||||
let new_token = generate_token();
|
||||
let (t2, n2) = crate::db::get_or_create_session(&dbi.db, &new_token);
|
||||
crate::db::set_user_agent_hash(&dbi.db, &t2, &user_agent_hash);
|
||||
token = t2;
|
||||
let new_plain = generate_token();
|
||||
let (a2, n2) = crate::db::get_or_create_session(&dbi.db, &new_plain);
|
||||
crate::db::set_user_agent_hash(
|
||||
&dbi.db,
|
||||
&crate::db::token_hash(&new_plain),
|
||||
&user_agent_hash,
|
||||
);
|
||||
token = a2;
|
||||
cookie_token = new_plain;
|
||||
name = n2;
|
||||
is_new = true;
|
||||
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())
|
||||
.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 bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
|
||||
@@ -131,7 +131,7 @@ pub fn create_registration_challenge(
|
||||
authenticator_attachment: None,
|
||||
resident_key: "preferred".into(),
|
||||
require_resident_key: false,
|
||||
user_verification: "preferred".into(),
|
||||
user_verification: "required".into(),
|
||||
},
|
||||
attestation: "none".into(),
|
||||
}
|
||||
@@ -204,6 +204,10 @@ pub fn verify_registration(
|
||||
if flags & 0x01 == 0 {
|
||||
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 {
|
||||
return Err("no attested credential data".into());
|
||||
}
|
||||
@@ -301,6 +305,9 @@ pub fn verify_authentication(
|
||||
if auth_data[32] & 0x01 == 0 {
|
||||
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]]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence, MotionConfig } from 'motion/react'
|
||||
import { IconShare2 } from '@tabler/icons-react'
|
||||
import Grid from './components/Grid'
|
||||
import CluePanel from './components/CluePanel'
|
||||
import ClueNav from './components/ClueNav'
|
||||
import ActiveClue from './components/ActiveClue'
|
||||
import Header from './components/Header'
|
||||
import InfoBar from './components/InfoBar'
|
||||
@@ -309,6 +310,7 @@ export default function App() {
|
||||
<div className="grid-column">
|
||||
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
|
||||
<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"
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function AchievementsPanel({ onClose }: { onClose: () => void })
|
||||
<div
|
||||
key={a.id}
|
||||
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
|
||||
className="ach-icon"
|
||||
@@ -114,7 +114,7 @@ export default function AchievementsPanel({ onClose }: { onClose: () => void })
|
||||
background: `linear-gradient(135deg, ${a.color1}, ${a.color2})`,
|
||||
} : undefined}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<Icon size={20} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ach-info">
|
||||
<div className="ach-name">{a.name}</div>
|
||||
|
||||
@@ -249,7 +249,8 @@ function Dialog({ title, children, onClose, actions }: {
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<h3>{title}</h3>
|
||||
<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">
|
||||
<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>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder={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={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSetup()} placeholder={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" />
|
||||
{setupError && <p className="admin-login-error">{setupError}</p>}
|
||||
<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')} 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')} aria-label={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" />
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-login-form">
|
||||
{setupSuccess && <p style={{ color: 'var(--color-correct)', fontSize: '0.7rem', margin: '0 0 8px' }}>{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="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" />
|
||||
{loginError && <p className="admin-login-error">{loginError}</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')} 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')} aria-label={t('admin.password')} className="admin-input" autoComplete="current-password" />
|
||||
{loginError && <p className="admin-login-error" role="alert">{loginError}</p>}
|
||||
<button className="btn btn-primary" onClick={login} style={{ width: '100%' }}>{t('common.signIn')}</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -772,12 +773,12 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
</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">
|
||||
<nav className="admin-nav" aria-label="Admin sections">
|
||||
<nav className="admin-nav" aria-label="Admin sections" role="tablist">
|
||||
{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}
|
||||
{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>
|
||||
@@ -843,7 +844,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="admin-toolbar">
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['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}`)}
|
||||
</button>
|
||||
))}
|
||||
@@ -854,7 +855,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
)}
|
||||
</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 => (
|
||||
<tr key={p.id}>
|
||||
<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.rating > 0 ? `${p.rating}/5` : '-'}</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>
|
||||
))}
|
||||
</tbody></table>
|
||||
@@ -879,7 +880,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
</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 => (
|
||||
<tr key={p.id}>
|
||||
<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.width}x{p.height}</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>
|
||||
))}
|
||||
</tbody></table>
|
||||
@@ -929,7 +930,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
<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>
|
||||
<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)}
|
||||
onKeyDown={e => e.key === 'Enter' && addBlockedWord()}
|
||||
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 && (<>
|
||||
<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>
|
||||
<span className="admin-toolbar-count">{t('admin.users.userCount', { count: filteredSessions.length })}</span>
|
||||
</div>
|
||||
@@ -956,10 +957,10 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
<td className="admin-mono">{new Date(s.created_at).toLocaleDateString()}</td>
|
||||
<td style={{ display: 'flex', gap: 4 }}>
|
||||
{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.ban')}><button className="cc-delete" onClick={() => banUser(s.token, s.display_name ?? '')}><IconBan 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" 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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -979,7 +980,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
{tab === 'integrations' && !tabLoading.integrations && (<>
|
||||
<h3 className="admin-section-heading">{t('admin.integrations.webhooks')}</h3>
|
||||
<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={[
|
||||
{ value: 'daily_puzzle.published', label: t('admin.integrations.dailyPublished') },
|
||||
{ value: 'puzzle.completed', label: t('admin.integrations.puzzleCompleted') },
|
||||
@@ -1034,6 +1035,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
<textarea
|
||||
ref={rulesRef}
|
||||
className="admin-input"
|
||||
aria-label="Instance rules"
|
||||
rows={6}
|
||||
value={String(config?.instance_rules ?? '')}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{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}>
|
||||
<td className="admin-mono" style={{ maxWidth: '24rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</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 () => {
|
||||
await adminFetch(`/outbox/${i}`, { method: 'DELETE' })
|
||||
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 }}>
|
||||
<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)}
|
||||
onKeyDown={e => e.key === 'Enter' && deleteUri.trim() && (async () => {
|
||||
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 */}
|
||||
<h3 className="admin-section-heading">{t('admin.federation.peersTitle')}</h3>
|
||||
<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)}
|
||||
onKeyDown={e => e.key === 'Enter' && connectPeer()}
|
||||
className="admin-input" />
|
||||
@@ -1167,7 +1169,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
<tr key={p.domain}>
|
||||
<td className="admin-mono">{p.domain}</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>
|
||||
))}</tbody>
|
||||
</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">{new Date(f.created_at).toLocaleDateString()}</td>
|
||||
<td style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="btn btn-sm" onClick={() => approveFollower(f.actor_uri)}><IconCheckFilled size={11} /></button>
|
||||
<button className="cc-delete" onClick={() => rejectFollower(f.actor_uri)}><IconSquareXFilled 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" aria-label="Reject follower" onClick={() => rejectFollower(f.actor_uri)}><IconSquareXFilled size={11} aria-hidden="true" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
@@ -1196,7 +1198,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
{/* Security */}
|
||||
<h3 className="admin-section-heading">{t('admin.federation.security')}</h3>
|
||||
<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' }} />
|
||||
<button className="btn btn-sm" onClick={blockInstance} disabled={!blockDomain.trim()}>{t('common.block')}</button>
|
||||
</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')})
|
||||
</label>
|
||||
<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')}
|
||||
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')})
|
||||
</label>
|
||||
<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')}
|
||||
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')})
|
||||
</label>
|
||||
<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')}
|
||||
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'}`
|
||||
|
||||
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 && (
|
||||
<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>
|
||||
))}
|
||||
</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
|
||||
|
||||
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) => {
|
||||
const barH = (val / maxVal) * h
|
||||
const x = pad.left + i * gap + (gap - barW) / 2
|
||||
@@ -257,6 +265,10 @@ function BarChart({ data, labels, width, height, highlightMax, highlightMin, for
|
||||
)
|
||||
})}
|
||||
</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
|
||||
|
||||
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) => {
|
||||
const barH = (val / peak) * h
|
||||
const x = pad.left + i * gap + (gap - barW) / 2
|
||||
@@ -336,7 +348,7 @@ function DonutChart({ segments, size }: {
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<circle key={i} cx={cx} cy={cy} r={innerR}
|
||||
fill="none" stroke={a.color} strokeWidth={strokeW}
|
||||
@@ -386,7 +398,7 @@ function GaugeChart({ value, size }: { value: number; size: number }) {
|
||||
|
||||
return (
|
||||
<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"
|
||||
stroke="var(--border)" strokeWidth={8} strokeLinecap="round" />
|
||||
{value > 0 && (
|
||||
@@ -450,7 +462,7 @@ function ActivityHeatmap({ calendar }: { calendar: Record<string, number> }) {
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<text key={i} x={24 + m.col * total} y={8}
|
||||
className="analytics-chart-tick">{m.label}</text>
|
||||
|
||||
@@ -17,6 +17,9 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
||||
const puzzle = useStore((s) => s.puzzle)
|
||||
const [flips, setFlips] = useState<FlipItem[]>([])
|
||||
const prevEntries = useRef<string[][]>([])
|
||||
const timeouts = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => () => { timeouts.current.forEach(id => clearTimeout(id)) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!puzzle) return
|
||||
@@ -50,7 +53,7 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
||||
}
|
||||
|
||||
setFlips(f => [...f, ...newFlips])
|
||||
setTimeout(() => {
|
||||
timeouts.current.push(setTimeout(() => {
|
||||
// restore SVG letter visibility
|
||||
if (svgRef.current) {
|
||||
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)))
|
||||
}, 600)
|
||||
}, 600))
|
||||
}
|
||||
}, [entries, puzzle, svgRef])
|
||||
|
||||
@@ -92,13 +95,13 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
|
||||
{flip.clueNumber && (
|
||||
<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 className="flip-face flip-back">
|
||||
{flip.clueNumber && (
|
||||
<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>
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<button
|
||||
className={`clue-tab ${viewTab === 'across' ? 'active' : ''}`}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ClueTrainer({ onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
|
||||
<IconX size={16} />
|
||||
<IconX size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function ClueTrainer({ onClose }: Props) {
|
||||
{(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => (
|
||||
<button key={d}
|
||||
className={`trainer-diff-btn ${difficulty === d ? 'trainer-diff-active' : ''}`}
|
||||
aria-pressed={difficulty === d}
|
||||
onClick={() => changeDifficulty(d)}>
|
||||
{t(`trainer.${d}`)}
|
||||
</button>
|
||||
@@ -143,7 +144,7 @@ export default function ClueTrainer({ onClose }: Props) {
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<><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, ''))}
|
||||
onKeyDown={e => e.key === 'Enter' && search()}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()}>
|
||||
<IconSearch size={14} />
|
||||
<button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()} aria-label="Search">
|
||||
<IconSearch size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -128,8 +128,8 @@ export default function CommunityCluesPanel({ onClose }: { onClose: () => void }
|
||||
maxLength={500}
|
||||
onKeyDown={e => e.key === 'Enter' && submitClue()}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()}>
|
||||
<IconSend2 size={14} />
|
||||
<button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()} aria-label={t('game.submit')}>
|
||||
<IconSend2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,14 +43,16 @@ export default function CommunityToday() {
|
||||
|
||||
<div className="ed-puzzle-grid">
|
||||
{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-title">{p.title}</span>
|
||||
<span className="ed-puzzle-by">
|
||||
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
||||
</span>
|
||||
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||
</button>
|
||||
<span className="ed-puzzle-report" onClick={e => e.stopPropagation()}><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,24 @@ export default function DisambiguationPopup({ svgRef }: Props) {
|
||||
}
|
||||
}, [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
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="disambiguation"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Choose a handwriting match"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: geom.top,
|
||||
@@ -62,6 +74,7 @@ export default function DisambiguationPopup({ svgRef }: Props) {
|
||||
<button
|
||||
className="disambiguation-btn"
|
||||
onClick={() => acceptCandidate(c.letter)}
|
||||
aria-label={`Select ${c.letter}`}
|
||||
>
|
||||
{c.letter}
|
||||
</button>
|
||||
@@ -71,6 +84,7 @@ export default function DisambiguationPopup({ svgRef }: Props) {
|
||||
<button
|
||||
className="disambiguation-btn dismiss"
|
||||
onClick={dismissDisambiguation}
|
||||
aria-label={t('common.dismiss')}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
|
||||
@@ -414,7 +414,10 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
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 (hasOffensivePattern(next)) return
|
||||
if (hasOffensivePattern(next)) {
|
||||
setWarnings([t('editor.offensivePattern')])
|
||||
return
|
||||
}
|
||||
pushUndo()
|
||||
setCells(next); setLetters(nextLetters); setClues([])
|
||||
} else if (mode === 'fill') {
|
||||
@@ -575,6 +578,62 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
const displayLetters = isTest ? testEntries : letters
|
||||
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') {
|
||||
return (
|
||||
<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">
|
||||
<label className="settings-label" style={{ marginTop: 8 }}>{t('editor.customSize')}</label>
|
||||
<div className="settings-row">
|
||||
<NumberInput value={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" />
|
||||
<span 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={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" aria-label={t('editor.width')} />
|
||||
<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" 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -607,8 +666,8 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
<span className={`cc-status cc-status-${p.status}`}>{p.status}</span>
|
||||
{p.status === 'published' && (
|
||||
<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' }}>
|
||||
<IconChartAreaFilled size={11} />
|
||||
<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} aria-hidden="true" />
|
||||
</button>
|
||||
</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="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>
|
||||
@@ -711,14 +770,14 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
<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="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 className="editor-header-actions">
|
||||
<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 === '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 === 'clues' ? 'btn-primary' : ''}`} onClick={generateClues}><IconListFilled size={12} 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>}
|
||||
<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' : ''}`} 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' : ''}`} 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' : ''}`} 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>
|
||||
<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>
|
||||
@@ -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>}
|
||||
{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>
|
||||
|
||||
{(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} />
|
||||
{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>}
|
||||
@@ -750,22 +809,38 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
|
||||
<div className="editor-workspace">
|
||||
<div className="editor-grid-area">
|
||||
<svg viewBox={`0 0 ${width * cellSize} ${height * cellSize}`} style={{ maxWidth: width * cellSize, width: '100%' }}>
|
||||
{cells.map((row, r) => row.map((cell, c) => {
|
||||
const isSel = displaySelected?.row === r && displaySelected?.col === c
|
||||
const isHighlighted = highlightedCells.has(`${r},${c}`)
|
||||
const val = displayLetters[r]?.[c]
|
||||
return (
|
||||
<g key={`${r}-${c}`} onClick={() => onCellClick(r, c)} style={{ cursor: 'pointer' }}>
|
||||
<rect x={c * cellSize} y={r * cellSize} width={cellSize} height={cellSize}
|
||||
fill={cell === 'black' ? 'var(--text)' : isSel ? 'var(--cell-selected, #bfdbfe)' : isHighlighted ? 'var(--accent-soft, #e0e7ff)' : 'var(--surface)'}
|
||||
stroke="var(--border)" strokeWidth={0.5} />
|
||||
{cellNumbers[`${r},${c}`] && <text x={c * cellSize + 2} y={r * cellSize + cellSize * 0.3} fontSize={cellSize * 0.25} fill="var(--text-tertiary)" fontFamily="var(--font-mono)">{cellNumbers[`${r},${c}`]}</text>}
|
||||
{val && <text x={c * cellSize + cellSize / 2} y={r * cellSize + cellSize * 0.75} fontSize={cellSize * 0.5} fill="var(--text)" fontFamily="var(--font-display)" fontWeight="700" textAnchor="middle">{val}</text>}
|
||||
</g>
|
||||
)
|
||||
}))}
|
||||
</svg>
|
||||
<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) => {
|
||||
const isSel = displaySelected?.row === r && displaySelected?.col === c
|
||||
const isHighlighted = highlightedCells.has(`${r},${c}`)
|
||||
const val = displayLetters[r]?.[c]
|
||||
return (
|
||||
<g key={`${r}-${c}`} onClick={() => onCellClick(r, c)} style={{ cursor: 'pointer' }}>
|
||||
<rect x={c * cellSize} y={r * cellSize} width={cellSize} height={cellSize}
|
||||
fill={cell === 'black' ? 'var(--text)' : isSel ? 'var(--cell-selected, #bfdbfe)' : isHighlighted ? 'var(--accent-soft, #e0e7ff)' : 'var(--surface)'}
|
||||
stroke="var(--border)" strokeWidth={0.5} />
|
||||
{cellNumbers[`${r},${c}`] && <text x={c * cellSize + 2} y={r * cellSize + cellSize * 0.3} fontSize={cellSize * 0.25} fill="var(--text-tertiary)" fontFamily="var(--font-mono)">{cellNumbers[`${r},${c}`]}</text>}
|
||||
{val && <text x={c * cellSize + cellSize / 2} y={r * cellSize + cellSize * 0.75} fontSize={cellSize * 0.5} fill="var(--text)" fontFamily="var(--font-display)" fontWeight="700" textAnchor="middle">{val}</text>}
|
||||
</g>
|
||||
)
|
||||
}))}
|
||||
</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">
|
||||
{mode === 'design' ? t('editor.designHint')
|
||||
: mode === 'fill' ? t('editor.fillHint', { dir: editorDirection })
|
||||
@@ -773,7 +848,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
: t('editor.clueHint')}
|
||||
</div>
|
||||
{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: 'mirror', label: t('editor.mirrorSymmetry') },
|
||||
{ value: 'none', label: t('editor.noSymmetry') },
|
||||
@@ -826,7 +901,9 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{showPublishConfirm && (
|
||||
<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>
|
||||
<p>{t('editor.publishDesc')}</p>
|
||||
<p>{t('editor.publishNote')}</p>
|
||||
@@ -835,7 +912,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
|
||||
{t('editor.isThemed')}
|
||||
</label>
|
||||
{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)}
|
||||
style={{ width: '100%', marginTop: 6, marginBottom: 6, padding: '0.375rem 0.5rem',
|
||||
fontFamily: 'var(--font-body)', fontSize: '0.75rem',
|
||||
|
||||
@@ -4,6 +4,8 @@ import Grid from './Grid'
|
||||
import CluePanel from './CluePanel'
|
||||
import ActiveClue from './ActiveClue'
|
||||
import { useKeyboard } from '../hooks/useKeyboard'
|
||||
import { useNarrator } from '../hooks/useNarrator'
|
||||
import { useSounds } from '../hooks/useSounds'
|
||||
import { useStore } from '../store'
|
||||
import type { ClientPuzzle } from '../types'
|
||||
|
||||
@@ -53,6 +55,8 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
||||
const paused = useStore(s => s.paused)
|
||||
const tick = useStore(s => s.tick)
|
||||
useKeyboard()
|
||||
useNarrator()
|
||||
useSounds()
|
||||
|
||||
// timer
|
||||
useEffect(() => {
|
||||
@@ -102,8 +106,8 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
||||
}
|
||||
}, [solved, elapsed])
|
||||
|
||||
if (loading) return <div className="embed-loading"><div className="spinner" /></div>
|
||||
if (error) return <div className="embed-error">{error}</div>
|
||||
if (loading) return <div className="embed-loading" role="status"><div className="spinner" /></div>
|
||||
if (error) return <div className="embed-error" role="alert">{error}</div>
|
||||
if (!puzzle) return null
|
||||
|
||||
const mins = Math.floor(elapsed / 60)
|
||||
@@ -111,7 +115,7 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="embed-brand">{t('embed.brand')}</span>
|
||||
{showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>}
|
||||
|
||||
@@ -14,6 +14,15 @@ function getCellSize(gridSize: number): number {
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
@@ -42,6 +51,22 @@ export default function Grid() {
|
||||
cellRefs.current[idx]?.focus()
|
||||
}, [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
|
||||
|
||||
const CELL = getCellSize(Math.max(width, height))
|
||||
@@ -55,31 +80,79 @@ export default function Grid() {
|
||||
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
|
||||
)
|
||||
|
||||
// on touch devices, move focus into the hidden typing field so the
|
||||
// on-screen keyboard appears, with the tapped cell announced as its label
|
||||
// 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()
|
||||
if (!st.puzzle) return t('grid.cellEmpty')
|
||||
if (st.puzzle.cells[r][c] === 'black') return t('grid.blackCell')
|
||||
const num = st.puzzle.cell_numbers[`${r},${c}`]
|
||||
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'
|
||||
return t('grid.cellLabel', { row: r + 1, col: c + 1 }) +
|
||||
|
||||
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) return
|
||||
const field = typingRef.current
|
||||
if (field) {
|
||||
field.setAttribute('aria-label', buildCellLabel(r, c))
|
||||
field.focus({ preventScroll: true })
|
||||
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 (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
@@ -128,7 +201,13 @@ export default function Grid() {
|
||||
id={`cell-${r}-${c}`}
|
||||
data-cell={`${r},${c}`}
|
||||
onClick={() => {
|
||||
if (isBlack) return
|
||||
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)
|
||||
}}
|
||||
@@ -239,6 +318,7 @@ export default function Grid() {
|
||||
const isBlack = puzzle.cells[r][c] === 'black'
|
||||
const isSelected = selectedCell?.row === r && selectedCell?.col === c
|
||||
const label = buildCellLabel(r, c)
|
||||
const braille = buildCellBrailleLabel(r, c)
|
||||
return (
|
||||
<button
|
||||
key={`sr-${r}-${c}`}
|
||||
@@ -248,8 +328,10 @@ export default function Grid() {
|
||||
role="gridcell"
|
||||
aria-rowindex={r + 1}
|
||||
aria-colindex={c + 1}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
tabIndex={r * width + c === focusableIdx ? 0 : -1}
|
||||
aria-label={label}
|
||||
aria-braillelabel={braille}
|
||||
onFocus={() => { if (!isSelected && !isBlack) selectCell(r, c) }}
|
||||
onClick={() => !isBlack && selectCell(r, c)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { IconFlameFilled, IconShare, IconSettings, IconPlus, IconTrophyFilled,
|
||||
IconMessageFilled, IconPencilFilled, IconDotsFilled, IconKeyFilled, IconWorldFilled,
|
||||
IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled, IconDeviceSpeakerFilled,
|
||||
IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled,
|
||||
IconKeyboardFilled, IconX, IconClockFilled, IconArchiveFilled, IconSnowflake,
|
||||
IconTargetArrow } from '@tabler/icons-react'
|
||||
import { useStore } from '../store'
|
||||
@@ -29,7 +29,6 @@ export default function Header() {
|
||||
const shortId = useStore((s) => s.shortId)
|
||||
const streak = useStore((s) => s.streak)
|
||||
const streakFreezes = useStore((s) => s.streakFreezes)
|
||||
const narratorEnabled = useStore((s) => s.narratorEnabled)
|
||||
const mpRoomCode = useStore((s) => s.mpRoomCode)
|
||||
const mpPlayers = useStore((s) => s.mpPlayers)
|
||||
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>
|
||||
</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 && (
|
||||
<button type="button" className="header-room-tag" onClick={() => setShowMultiplayer(true)}>
|
||||
<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>
|
||||
</div>
|
||||
<div className="sk-row">
|
||||
<div className="sk-keys"><kbd>Tab</kbd></div>
|
||||
<span className="sk-desc">{t('shortcuts.tabNext')}</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 className="sk-keys"><kbd>Home</kbd> / <kbd>End</kbd></div>
|
||||
<span className="sk-desc">{t('shortcuts.homeEnd')}</span>
|
||||
</div>
|
||||
<div className="sk-row">
|
||||
<div className="sk-keys"><kbd>Space</kbd></div>
|
||||
@@ -379,19 +367,11 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
<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-row">
|
||||
<div className="sk-keys"><kbd>?</kbd></div>
|
||||
<span className="sk-desc">{t('shortcuts.question')}</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 className="sk-keys"><kbd>Alt</kbd> + <kbd>W</kbd></div>
|
||||
<span className="sk-desc">{t('shortcuts.readWord')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function InfoBar() {
|
||||
return (
|
||||
<div className="info-bar">
|
||||
<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" />}
|
||||
{isMarathon && <IconPlayerTrackNextFilled size={11} style={{ color: '#60a5fa', marginRight: 2 }} 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">
|
||||
<button
|
||||
className={`mp-mode-btn ${mode === 'coop' ? 'mp-mode-active' : ''}`}
|
||||
aria-pressed={mode === 'coop'}
|
||||
onClick={() => setMode('coop')}
|
||||
>
|
||||
<IconHeartFilled size={16} />
|
||||
<IconHeartFilled size={16} aria-hidden="true" />
|
||||
<span>{t('multiplayer.coop')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mp-mode-btn ${mode === 'competitive' ? 'mp-mode-active' : ''}`}
|
||||
aria-pressed={mode === 'competitive'}
|
||||
onClick={() => setMode('competitive')}
|
||||
>
|
||||
<IconBoltFilled size={16} />
|
||||
<IconBoltFilled size={16} aria-hidden="true" />
|
||||
<span>{t('multiplayer.race')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mp-mode-btn ${mode === 'party' ? 'mp-mode-active' : ''}`}
|
||||
aria-pressed={mode === 'party'}
|
||||
onClick={(e) => {
|
||||
const wasParty = mode === 'party'
|
||||
setMode('party')
|
||||
@@ -217,13 +220,14 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconConfettiFilled size={16} />
|
||||
<IconConfettiFilled size={16} aria-hidden="true" />
|
||||
<span>{t('multiplayer.partyLabel')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{mode === 'party' && (
|
||||
<motion.button type="button" key="party-sub" ref={partyBtnRef} className="mp-party-sub-selected"
|
||||
aria-haspopup="listbox" aria-expanded={partyDropOpen}
|
||||
onClick={() => setPartyDropOpen(!partyDropOpen)}
|
||||
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
|
||||
@@ -287,8 +291,9 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleJoin()}
|
||||
disabled={busy || joinCode.length < 4}
|
||||
aria-label={t('multiplayer.joinRoom')}
|
||||
>
|
||||
<IconArrowBigRightFilled size={14} />
|
||||
<IconArrowBigRightFilled size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function OnboardingModal() {
|
||||
type="text"
|
||||
className="onboard-username"
|
||||
placeholder={t('common.anonymous')}
|
||||
aria-label={t('onboarding.chooseName')}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
maxLength={30}
|
||||
@@ -96,6 +97,7 @@ export default function OnboardingModal() {
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`}
|
||||
aria-pressed={lifetime === opt.value}
|
||||
onClick={() => setLifetime(opt.value)}
|
||||
>
|
||||
{t(opt.key)}
|
||||
@@ -120,7 +122,7 @@ export default function OnboardingModal() {
|
||||
{registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')}
|
||||
</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>}
|
||||
|
||||
@@ -154,7 +156,7 @@ export default function OnboardingModal() {
|
||||
<IconArrowRight size={14} />
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ function Scoreboard({ scores, myId }: { scores: { id: string; name: string; colo
|
||||
const { t } = useTranslation()
|
||||
const sorted = [...scores].sort((a, b) => b.score - a.score)
|
||||
return (
|
||||
<div className="party-scoreboard">
|
||||
<div className="party-scoreboard" aria-live="polite">
|
||||
<div className="party-scoreboard-title">{t('party.scores')}</div>
|
||||
{sorted.map((p, i) => (
|
||||
<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"
|
||||
className="party-submitted"
|
||||
role="status"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
@@ -180,7 +181,7 @@ function VotePhase({ prompt }: { prompt: string }) {
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{myVote && (
|
||||
<motion.div className="party-voted"
|
||||
<motion.div className="party-voted" role="status"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
{t('party.voteLocked')}
|
||||
@@ -339,7 +340,7 @@ function SpeedRacePhase() {
|
||||
|
||||
<AnimatePresence>
|
||||
{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 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<AnimatePresence>
|
||||
@@ -403,7 +404,7 @@ function RouletteChoosePhase() {
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{myPick && (
|
||||
<motion.div className="party-voted"
|
||||
<motion.div className="party-voted" role="status"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
{t('party.lockedIn')}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<motion.div className="settings-panel" onClick={e => e.stopPropagation()}
|
||||
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 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
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 }}>
|
||||
{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' }}>
|
||||
{p === 'all' ? t('archive.all') : p}
|
||||
</button>
|
||||
@@ -88,7 +89,9 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
|
||||
)}
|
||||
<div className="cc-list">
|
||||
{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 }}>
|
||||
{entry.solved && <IconCircleCheckFilled size={13} style={{ color: 'var(--color-correct)', flexShrink: 0 }} aria-hidden="true" />}
|
||||
<span>{entry.date}</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IconSunFilled, IconMoonFilled, IconDeviceDesktop, IconEyeFilled, IconTe
|
||||
IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight,
|
||||
IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled,
|
||||
IconAccessibleFilled } from '@tabler/icons-react'
|
||||
import * as narrator from '../narrator'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useStore } from '../store'
|
||||
import * as api from '../api'
|
||||
@@ -91,95 +90,23 @@ function Toggle({ checked, onChange, label, hint, disabled, disabledHint }: { ch
|
||||
|
||||
function NarratorSettingsBlock() {
|
||||
const { t } = useTranslation()
|
||||
const narratorEnabled = useStore(s => s.narratorEnabled)
|
||||
const narratorSettings = useStore(s => s.narratorSettings)
|
||||
const setNarratorEnabled = useStore(s => s.setNarratorEnabled)
|
||||
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 (
|
||||
<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">
|
||||
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
|
||||
<Select value={narratorSettings.verbosity}
|
||||
onChange={v => { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }}
|
||||
options={[
|
||||
{ value: 'brief', label: t('settings.narrator.brief') },
|
||||
{ value: 'normal', label: t('settings.narrator.normal') },
|
||||
{ value: 'verbose', label: t('settings.narrator.verbose') },
|
||||
]} />
|
||||
</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 })}
|
||||
label={t('settings.narrator.soundEffects')} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="settings-narrator-item">
|
||||
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
|
||||
<Select value={narratorSettings.verbosity}
|
||||
onChange={v => { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }}
|
||||
options={[
|
||||
{ value: 'brief', label: t('settings.narrator.brief') },
|
||||
{ value: 'normal', label: t('settings.narrator.normal') },
|
||||
{ value: 'verbose', label: t('settings.narrator.verbose') },
|
||||
]} />
|
||||
</div>
|
||||
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
|
||||
label={t('settings.narrator.soundEffects')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,6 +125,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
const [newBlockedDomain, setNewBlockedDomain] = useState('')
|
||||
const [tab, setTab] = useState<Tab>('profile')
|
||||
const [migrateDomain, setMigrateDomain] = useState('')
|
||||
const [importMsg, setImportMsg] = useState('')
|
||||
const [migrating, setMigrating] = useState(false)
|
||||
const [migrateMsg, setMigrateMsg] = 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>
|
||||
<div className="settings-row">
|
||||
{(['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 === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> :
|
||||
<><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>
|
||||
<div className="settings-row">
|
||||
{(['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 })) }}>
|
||||
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
|
||||
</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>
|
||||
<div className="settings-grid-3">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -420,7 +348,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
) : (
|
||||
<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 && (
|
||||
<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')}
|
||||
@@ -518,7 +446,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
{settings.blockedInstances.length > 0 && (
|
||||
<div className="privacy-blocked-list">
|
||||
{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>
|
||||
)}
|
||||
@@ -557,14 +485,14 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
const res = await api.importData(data)
|
||||
let msg = t('onboarding.imported', { count: res.imported })
|
||||
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
|
||||
alert(msg)
|
||||
window.location.reload()
|
||||
} catch (e) { alert(e instanceof Error ? e.message : t('onboarding.importFailed')) }
|
||||
setImportMsg(msg)
|
||||
} catch (e) { setImportMsg(e instanceof Error ? e.message : t('onboarding.importFailed')) }
|
||||
}; input.click()
|
||||
}}>
|
||||
{t('settings.account.import')}
|
||||
</button>
|
||||
</div>
|
||||
{importMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{importMsg}</p>}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -637,7 +565,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
.finally(() => setMigrating(false))
|
||||
}}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button>
|
||||
</div>
|
||||
{migrateMsg && <p className="settings-hint" style={{ marginTop: 8 }}>{migrateMsg}</p>}
|
||||
{migrateMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{migrateMsg}</p>}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,9 +20,23 @@ export default function SolveHeatmap() {
|
||||
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 (
|
||||
<div className="solve-heatmap">
|
||||
<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">
|
||||
{Array.from({ length: puzzle.height }, (_, r) =>
|
||||
Array.from({ length: puzzle.width }, (_, c) => (
|
||||
|
||||
@@ -66,11 +66,18 @@ function RichSelect({ value, options, onChange, label }: {
|
||||
}
|
||||
}, [open, updatePos])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
dropRef.current?.querySelector<HTMLElement>('button')?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="ed-rich-select">
|
||||
<label className="ed-select-label">{label}</label>
|
||||
<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" />}
|
||||
<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" />
|
||||
@@ -243,7 +250,7 @@ export default function Welcome() {
|
||||
|
||||
{/* custom puzzle */}
|
||||
<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>
|
||||
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCustomPanel ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
@@ -373,7 +380,7 @@ export default function Welcome() {
|
||||
{/* community puzzles */}
|
||||
{allCommunity.length > 0 && (
|
||||
<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>
|
||||
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCommunity ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
@@ -385,15 +392,16 @@ export default function Welcome() {
|
||||
style={{ overflow: 'hidden' }}>
|
||||
<div className="ed-puzzle-grid">
|
||||
{allCommunity.slice(0, 8).map(p => (
|
||||
<button key={p.id} type="button" className="ed-puzzle-card"
|
||||
onClick={() => !loading && loadCommunityPuzzle(p.id)} disabled={loading}>
|
||||
<div key={p.id} className="ed-puzzle-card" role="button" tabIndex={0}
|
||||
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-title">{p.title}</span>
|
||||
<span className="ed-puzzle-by">
|
||||
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
||||
</span>
|
||||
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||
</button>
|
||||
<span className="ed-puzzle-report" onClick={e => e.stopPropagation()}><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -38,11 +38,18 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const calRef = useRef<HTMLDivElement>(null)
|
||||
const dayRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const [focusDay, setFocusDay] = useState<number | null>(null)
|
||||
const { year, month } = parse(value)
|
||||
const [viewYear, setViewYear] = useState(year)
|
||||
const [viewMonth, setViewMonth] = useState(month)
|
||||
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(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
@@ -60,7 +67,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
@@ -87,12 +94,38 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
||||
|
||||
const pick = (d: number) => {
|
||||
onChange(fmt(viewYear, viewMonth, d))
|
||||
setFocusDay(null)
|
||||
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 (
|
||||
<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>
|
||||
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
||||
</button>
|
||||
@@ -107,7 +140,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
||||
<div className="cv-cal-days" role="grid">
|
||||
{WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)}
|
||||
</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: days }, (_, i) => {
|
||||
const d = i + 1
|
||||
@@ -117,8 +150,10 @@ export default function DatePicker({ value, onChange, className }: Props) {
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
ref={el => { dayRefs.current[d - 1] = el }}
|
||||
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
|
||||
onClick={() => pick(d)}
|
||||
onFocus={() => setFocusDay(d)}
|
||||
type="button"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function Select({ value, options, onChange, className, style }: P
|
||||
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' : ''}`}
|
||||
onClick={() => { onChange(o.value); close() }}
|
||||
onClick={() => { onChange(o.value); close(); triggerRef.current?.focus() }}
|
||||
onKeyDown={e => onOptionKeyDown(e, i)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
|
||||
interface Props {
|
||||
@@ -59,6 +59,11 @@ export default function Tooltip({ text, children, delay = 400 }: Props) {
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -66,10 +71,9 @@ export default function Tooltip({ text, children, delay = 400 }: Props) {
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
aria-describedby={visible ? tipId : undefined}
|
||||
className="cv-tooltip-wrap"
|
||||
>
|
||||
{children}
|
||||
{child}
|
||||
{visible && createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
|
||||
@@ -1,117 +1,34 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import type { Direction } from '../types'
|
||||
import i18n from '../i18n'
|
||||
import * as narrator from '../narrator'
|
||||
|
||||
function readCurrentCell() {
|
||||
const t = i18n.t
|
||||
const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState()
|
||||
if (!puzzle || !selectedCell) {
|
||||
narrator.speak(t('a11y.noCellSelected'))
|
||||
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 = t('grid.cellLabel', { row: row + 1, col: col + 1 })
|
||||
if (num) msg += t('grid.cellNumber', { num })
|
||||
msg += letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')
|
||||
msg += `, ${t(direction === 'across' ? 'game.across' : 'game.down')}`
|
||||
if (pos && len) msg += t('a11y.position', { pos, len })
|
||||
narrator.speak(msg)
|
||||
}
|
||||
|
||||
function readWord(dir: Direction) {
|
||||
const t = i18n.t
|
||||
const { puzzle, selectedCell, entries, wordSpans } = useStore.getState()
|
||||
if (!puzzle || !selectedCell) {
|
||||
narrator.speak(t('a11y.noCellSelected'))
|
||||
return
|
||||
}
|
||||
const span = wordSpans.find(s =>
|
||||
s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col)
|
||||
)
|
||||
if (!span) {
|
||||
narrator.speak(t(dir === 'across' ? 'a11y.noAcrossWord' : 'a11y.noDownWord'), false)
|
||||
return
|
||||
}
|
||||
const letters = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.empty')).join(', ')
|
||||
const dirLabel = t(dir === 'across' ? 'game.across' : 'game.down')
|
||||
narrator.speak(t('a11y.wordReading', { dir: dirLabel, number: span.number, length: span.cells.length, letters }), false)
|
||||
}
|
||||
|
||||
function readCurrentClue() {
|
||||
const t = i18n.t
|
||||
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
|
||||
if (!puzzle || activeClue === null) {
|
||||
narrator.speak(t('a11y.noClueSelected'))
|
||||
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
|
||||
const dirLabel = t(direction === 'across' ? 'game.across' : 'game.down')
|
||||
|
||||
if (v === 'brief') {
|
||||
narrator.speak(t('a11y.clueBrief', { number: activeClue, dir: dirLabel, text: clue.text }))
|
||||
} else if (v === 'normal') {
|
||||
narrator.speak(t('a11y.clueNormal', { number: activeClue, dir: dirLabel, length: len, text: clue.text }))
|
||||
} else {
|
||||
narrator.speak(t('a11y.clueVerbose', { number: activeClue, dir: dirLabel, length: len, filled, text: clue.text }))
|
||||
}
|
||||
}
|
||||
|
||||
function readPuzzleOverview() {
|
||||
const t = i18n.t
|
||||
const { puzzle, entries, wordSpans } = useStore.getState()
|
||||
if (!puzzle) {
|
||||
narrator.speak(t('a11y.noPuzzleLoaded'))
|
||||
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(
|
||||
t('a11y.overview', {
|
||||
width: puzzle.width, height: puzzle.height,
|
||||
filled: filledCells, total: totalCells, remaining,
|
||||
})
|
||||
)
|
||||
}
|
||||
import { announce } from '../narrator'
|
||||
|
||||
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,
|
||||
// so typing after clicking a header button does not fill grid cells
|
||||
let gridHasFocus = false
|
||||
|
||||
function updateGridFocus(target: EventTarget | null) {
|
||||
gridHasFocus = !!getGridEl()?.contains(document.activeElement) || (target instanceof Element && !!getGridEl()?.contains(target))
|
||||
// Announce the current clue and the fill pattern of its word (e.g. "1 Across,
|
||||
// 6 letters: P, blank, blank, blank, blank, T. What is a baby boar called.").
|
||||
function readWordPattern() {
|
||||
const t = i18n.t
|
||||
const { puzzle, wordSpans, entries, activeClue, direction } = useStore.getState()
|
||||
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() {
|
||||
@@ -119,100 +36,42 @@ export function useKeyboard() {
|
||||
const typeLetter = useStore((s) => s.typeLetter)
|
||||
const deleteLetter = useStore((s) => s.deleteLetter)
|
||||
const moveSelection = useStore((s) => s.moveSelection)
|
||||
const moveRowEdge = useStore((s) => s.moveRowEdge)
|
||||
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 redo = useStore((s) => s.redo)
|
||||
|
||||
useEffect(() => {
|
||||
if (!puzzle) return
|
||||
|
||||
gridHasFocus = !!getGridEl()?.contains(document.activeElement)
|
||||
|
||||
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()
|
||||
}
|
||||
const grid = getGridEl()
|
||||
if (!grid) return
|
||||
|
||||
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 narr = useStore.getState().narratorEnabled
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
|
||||
// narrator-only shortcuts - only when grid is focused
|
||||
if (narr && gridFocused) {
|
||||
if (key === ' ') {
|
||||
e.preventDefault()
|
||||
readCurrentCell()
|
||||
return
|
||||
}
|
||||
if (key === '?' || key === '/' || (e.altKey && key.toLowerCase() === 'p')) {
|
||||
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) {
|
||||
if (ctrl && key.toLowerCase() === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
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()
|
||||
redo()
|
||||
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()
|
||||
typeLetter(key)
|
||||
return
|
||||
}
|
||||
if (gridFocused && key.length === 1 && key >= 'A' && key <= 'Z') {
|
||||
e.preventDefault()
|
||||
typeLetter(key)
|
||||
return
|
||||
}
|
||||
|
||||
if (!gridFocused) return
|
||||
|
||||
switch (key) {
|
||||
case 'Backspace':
|
||||
@@ -239,22 +98,22 @@ export function useKeyboard() {
|
||||
e.preventDefault()
|
||||
moveSelection(0, 1)
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
moveRowEdge(true)
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
moveRowEdge(false)
|
||||
break
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
toggleDirection()
|
||||
break
|
||||
case 'Tab':
|
||||
e.preventDefault()
|
||||
tabClue(!e.shiftKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
document.removeEventListener('focusin', onFocusIn)
|
||||
}
|
||||
}, [puzzle, typeLetter, deleteLetter, moveSelection, toggleDirection, tabClue, narratorEnabled, undo, redo])
|
||||
grid.addEventListener('keydown', onKeyDown)
|
||||
return () => grid.removeEventListener('keydown', onKeyDown)
|
||||
}, [puzzle, typeLetter, deleteLetter, moveSelection, moveRowEdge, toggleDirection, undo, redo])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import i18n from '../i18n'
|
||||
import * as narrator from '../narrator'
|
||||
import { announce } from '../narrator'
|
||||
|
||||
function formatTime(secs: number, t: (key: string, opts?: { count: number }) => string): string {
|
||||
const m = Math.floor(secs / 60)
|
||||
@@ -12,77 +12,54 @@ function formatTime(secs: number, t: (key: string, opts?: { count: number }) =>
|
||||
}
|
||||
|
||||
export function useNarrator() {
|
||||
const prevCell = useRef<{ row: number; col: number } | null>(null)
|
||||
const prevDirection = useRef<string | null>(null)
|
||||
const prevClueKey = useRef<string | null>(null)
|
||||
const prevEntries = useRef<string[][] | null>(null)
|
||||
const prevActiveClue = useRef<number | null>(null)
|
||||
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(() => {
|
||||
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
|
||||
|
||||
// direction change
|
||||
if (state.direction !== prevDirection.current && prevDirection.current !== null) {
|
||||
narrator.speak(t('a11y.nowSolving', { dir: t(state.direction === 'across' ? 'game.across' : 'game.down') }))
|
||||
}
|
||||
prevDirection.current = state.direction
|
||||
// Active clue change (moving into a new word or toggling direction).
|
||||
if (state.activeClue !== null) {
|
||||
const key = `${state.activeClue}-${state.direction}`
|
||||
if (key !== prevClueKey.current) {
|
||||
const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
|
||||
const clue = clues.find((c) => c.number === state.activeClue)
|
||||
if (clue) {
|
||||
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
|
||||
const dir = t(state.direction === 'across' ? 'game.across' : 'game.down')
|
||||
|
||||
// cell selection
|
||||
if (state.selectedCell &&
|
||||
(state.selectedCell.row !== prevCell.current?.row ||
|
||||
state.selectedCell.col !== prevCell.current?.col)) {
|
||||
|
||||
const { row, col } = state.selectedCell
|
||||
const letter = state.entries[row]?.[col] || ''
|
||||
const cellContent = letter ? letter : t('a11y.empty')
|
||||
|
||||
if (v === 'brief') {
|
||||
narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
|
||||
} else if (v === 'normal') {
|
||||
narrator.speak(`Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`)
|
||||
} else {
|
||||
const puzzle = state.puzzle
|
||||
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 (v === 'brief') {
|
||||
announce(t('a11y.clueBrief', { number: state.activeClue, dir, text: clue.text }))
|
||||
} else if (v === 'normal') {
|
||||
announce(t('a11y.clueNormal', { number: state.activeClue, dir, length: len, text: clue.text }))
|
||||
} else {
|
||||
announce(t('a11y.clueVerbose', { number: state.activeClue, dir, length: len, filled, text: clue.text }))
|
||||
}
|
||||
}
|
||||
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) {
|
||||
for (let r = 0; r < state.entries.length; r++) {
|
||||
for (let c = 0; c < state.entries[r].length; c++) {
|
||||
const cur = state.entries[r][c]
|
||||
const old = prevEntries.current[r]?.[c] || ''
|
||||
if (cur && !old) {
|
||||
if (v === 'brief') narrator.speak(cur, false)
|
||||
else if (v === 'normal') narrator.speak(t('a11y.entered', { letter: cur }), false)
|
||||
else narrator.speak(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }), false)
|
||||
if (v === 'brief') announce(cur)
|
||||
else if (v === 'normal') announce(t('a11y.entered', { letter: cur }))
|
||||
else announce(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }))
|
||||
|
||||
// check word completion
|
||||
const span = state.wordSpans.find(s =>
|
||||
s.cells.some(([sr, sc]) => sr === r && sc === c)
|
||||
// word completion
|
||||
const span = state.wordSpans.find((s) =>
|
||||
s.cells.some(([sr, sc]) => sr === r && sc === c),
|
||||
)
|
||||
if (span) {
|
||||
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
|
||||
@@ -90,58 +67,54 @@ export function useNarrator() {
|
||||
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
|
||||
if (!wasFilled) {
|
||||
const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('')
|
||||
setTimeout(() => narrator.speak(t('a11y.wordComplete', { word: 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
|
||||
|
||||
// clue focus change
|
||||
if (state.activeClue !== null && state.activeClue !== prevActiveClue.current) {
|
||||
const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
|
||||
const clue = clues.find(c => c.number === state.activeClue)
|
||||
if (clue) {
|
||||
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} ${t(state.direction === 'across' ? 'game.across' : 'game.down')}: ${clue.text}`, false)
|
||||
} else if (v === 'normal') {
|
||||
narrator.speak(t('a11y.clueNormal', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, text: clue.text }), false)
|
||||
} else {
|
||||
narrator.speak(t('a11y.clueVerbose', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, filled, text: clue.text }), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
prevActiveClue.current = state.activeClue
|
||||
|
||||
// hint reveal - detect newly revealed cells
|
||||
if (prev.cellStatus !== state.cellStatus && prevEntries.current) {
|
||||
// correct / incorrect / revealed cell feedback
|
||||
if (prev.cellStatus !== state.cellStatus) {
|
||||
let correct = 0
|
||||
let incorrect = 0
|
||||
const revealed: [number, number][] = []
|
||||
for (let r = 0; r < state.cellStatus.length; r++) {
|
||||
for (let c = 0; c < state.cellStatus[r].length; c++) {
|
||||
if (state.cellStatus[r][c] === 'revealed' && prev.cellStatus[r]?.[c] !== 'revealed') {
|
||||
const letter = state.entries[r][c]
|
||||
const span = state.wordSpans.find(s =>
|
||||
s.cells.some(([sr, sc]) => sr === r && sc === c)
|
||||
)
|
||||
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
|
||||
if (letter && pos) {
|
||||
narrator.speak(t('a11y.revealed', { letter, pos }), false)
|
||||
}
|
||||
}
|
||||
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 span = state.wordSpans.find((s) =>
|
||||
s.cells.some(([sr, sc]) => sr === r && sc === c),
|
||||
)
|
||||
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
|
||||
if (letter && pos) announce(t('a11y.revealed', { letter, pos }))
|
||||
}
|
||||
}
|
||||
|
||||
// puzzle solved
|
||||
if (state.solved && !prevSolved.current) {
|
||||
const time = formatTime(state.elapsed, t)
|
||||
narrator.speak(t('a11y.solvedTime', { time }))
|
||||
announce(t('a11y.solvedTime', { time }))
|
||||
}
|
||||
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>,
|
||||
) {}
|
||||
+44
-11
@@ -86,6 +86,36 @@
|
||||
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;
|
||||
@@ -567,6 +597,20 @@ body { font-family: var(--font-body); background: var(--bg); color: var(--text);
|
||||
.grid-wrapper:focus-within {
|
||||
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 {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
@@ -1195,8 +1239,6 @@ input[type="range"] { accent-color: var(--accent); }
|
||||
color: var(--text-tertiary); margin-top: 0.125rem;
|
||||
}
|
||||
.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-label {
|
||||
display: block; font-family: var(--font-mono); font-size: 0.563rem; font-weight: 500;
|
||||
@@ -3383,15 +3425,6 @@ svg text {
|
||||
}
|
||||
.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 {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
|
||||
@@ -91,9 +91,19 @@
|
||||
"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}}",
|
||||
@@ -118,6 +128,8 @@
|
||||
"cellIncorrect": ", incorrect",
|
||||
"cellRevealed": ", revealed",
|
||||
"cellPencil": ", pencil letter {{letter}}",
|
||||
"cellWord": ", {{number}} {{dir}}",
|
||||
"cellClue": ", {{number}} {{dir}}: {{clue}}",
|
||||
"handwritingCanvas": "Handwriting input area"
|
||||
},
|
||||
"welcome": {
|
||||
@@ -163,8 +175,6 @@
|
||||
"aboutInstance": "About this instance",
|
||||
"signInPasskey": "Sign in with passkey",
|
||||
"keyboardShortcuts": "Keyboard shortcuts",
|
||||
"narrator": "Narrator",
|
||||
"narratorActive": "Narrator active",
|
||||
"newPuzzle": "New puzzle",
|
||||
"startNewPuzzle": "Start a new puzzle",
|
||||
"custom": "Custom",
|
||||
@@ -191,20 +201,19 @@
|
||||
"subtitle": "Available while the grid is focused",
|
||||
"navigation": "Navigation",
|
||||
"editing": "Editing",
|
||||
"narrator": "Narrator mode",
|
||||
"arrows": "Move between cells",
|
||||
"tabNext": "Jump to next clue",
|
||||
"tabPrev": "Jump to previous clue",
|
||||
"space": "Toggle across / down",
|
||||
"escape": "Deselect cell",
|
||||
"homeEnd": "Jump to start / end of row",
|
||||
"reading": "Reading",
|
||||
"readWord": "Read current clue and word",
|
||||
"letters": "Enter a letter",
|
||||
"backspace": "Clear current cell",
|
||||
"delete": "Clear and move back",
|
||||
"ctrlZ": "Undo last entry",
|
||||
"ctrlY": "Redo last entry",
|
||||
"question": "Re-read current clue",
|
||||
"ctrlH": "Puzzle overview",
|
||||
"f1": "Puzzle overview"
|
||||
"ctrlY": "Redo last entry"
|
||||
},
|
||||
"difficulty": {
|
||||
"veryEasy": "Very easy",
|
||||
@@ -565,23 +574,13 @@
|
||||
"pushComingSoon": "Coming soon"
|
||||
},
|
||||
"narrator": {
|
||||
"title": "Narrator",
|
||||
"narratorMode": "Narrator mode",
|
||||
"hint": "Speaks cell positions, clues, and game events aloud",
|
||||
"speechRate": "Speech rate: {{rate}}x",
|
||||
"pitch": "Pitch: {{pitch}}x",
|
||||
"voice": "Voice",
|
||||
"defaultVoice": "Default",
|
||||
"title": "Screen reader",
|
||||
"hint": "Announces cell positions, clues, and game events to screen readers and braille displays",
|
||||
"verbosity": "Verbosity",
|
||||
"brief": "Brief",
|
||||
"normal": "Normal",
|
||||
"verbose": "Verbose",
|
||||
"output": "Output",
|
||||
"outputVoice": "Voice",
|
||||
"outputScreenReader": "Screen reader",
|
||||
"outputBoth": "Both",
|
||||
"soundEffects": "Sound effects",
|
||||
"notSupported": "Your browser does not support speech synthesis"
|
||||
"soundEffects": "Sound effects"
|
||||
},
|
||||
"session": {
|
||||
"duration": "Session duration",
|
||||
@@ -700,6 +699,11 @@
|
||||
"rotationalSymmetry": "Rotational symmetry",
|
||||
"mirrorSymmetry": "Mirror symmetry",
|
||||
"noSymmetry": "No symmetry",
|
||||
"symmetry": "Symmetry",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"delete": "Delete",
|
||||
"offensivePattern": "That grid pattern is not allowed",
|
||||
"checkAnswers": "Check answers",
|
||||
"cluesClickToEdit": "Clues (click to edit)",
|
||||
"writeClue": "Write a clue...",
|
||||
|
||||
+12
-73
@@ -1,98 +1,37 @@
|
||||
export interface NarratorSettings {
|
||||
enabled: boolean
|
||||
rate: number
|
||||
pitch: number
|
||||
voice: string
|
||||
verbosity: 'brief' | 'normal' | 'verbose'
|
||||
mode: 'voice' | 'screenreader' | 'both'
|
||||
sounds: boolean
|
||||
}
|
||||
|
||||
export const defaultNarratorSettings: NarratorSettings = {
|
||||
enabled: false,
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
voice: '',
|
||||
verbosity: 'normal',
|
||||
mode: 'voice',
|
||||
sounds: true,
|
||||
}
|
||||
|
||||
let settings: NarratorSettings = { ...defaultNarratorSettings }
|
||||
|
||||
export function updateSettings(s: NarratorSettings) {
|
||||
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
|
||||
// A polite live region that the screen reader (speech and braille) announces
|
||||
// from. There is no self-voicing here: announcing through the accessibility
|
||||
// tree lets the user's own screen reader and braille display do the reading.
|
||||
let srRegion: HTMLElement | null = null
|
||||
let regionTimer: number | undefined
|
||||
|
||||
function getSrRegion(): HTMLElement {
|
||||
if (!srRegion) {
|
||||
srRegion = document.createElement('div')
|
||||
srRegion.setAttribute('aria-live', 'polite')
|
||||
srRegion.setAttribute('role', 'status')
|
||||
srRegion.setAttribute('aria-atomic', 'true')
|
||||
srRegion.className = 'visually-hidden'
|
||||
document.body.appendChild(srRegion)
|
||||
}
|
||||
return srRegion
|
||||
}
|
||||
|
||||
function announceToScreenReader(text: string) {
|
||||
export function announce(text: string) {
|
||||
const region = getSrRegion()
|
||||
const msg = document.createElement('span')
|
||||
msg.textContent = `${text} `
|
||||
region.appendChild(msg)
|
||||
while (region.children.length > 6) {
|
||||
const first = region.firstChild
|
||||
if (first) region.removeChild(first)
|
||||
}
|
||||
setTimeout(() => msg.remove(), 15000)
|
||||
window.clearTimeout(regionTimer)
|
||||
// Clear then set in a later task so repeated identical text still re-announces.
|
||||
region.textContent = ''
|
||||
regionTimer = window.setTimeout(() => {
|
||||
region.textContent = text
|
||||
}, 0)
|
||||
}
|
||||
|
||||
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[][]
|
||||
togglePencilMode: () => void
|
||||
|
||||
narratorEnabled: boolean
|
||||
narratorSettings: NarratorSettings
|
||||
setNarratorEnabled: (enabled: boolean) => void
|
||||
setNarratorSettings: (settings: Partial<NarratorSettings>) => void
|
||||
|
||||
partySubmitAnswer: (answer: string) => void
|
||||
@@ -168,6 +166,7 @@ interface PuzzleStore {
|
||||
typeLetter: (letter: string) => void
|
||||
deleteLetter: () => void
|
||||
moveSelection: (dr: number, dc: number) => void
|
||||
moveRowEdge: (home: boolean) => void
|
||||
tabClue: (forward: boolean) => void
|
||||
deselectCell: () => void
|
||||
validateSolution: () => Promise<void>
|
||||
@@ -638,16 +637,10 @@ export const useStore = create<PuzzleStore>((set, get) => ({
|
||||
pencilMode: false,
|
||||
pencilEntries: [],
|
||||
|
||||
narratorEnabled: false,
|
||||
narratorSettings: { ...defaultNarratorSettings },
|
||||
|
||||
setNarratorEnabled: (enabled: boolean) => {
|
||||
set({ narratorEnabled: enabled, narratorSettings: { ...get().narratorSettings, enabled } })
|
||||
},
|
||||
|
||||
setNarratorSettings: (partial: Partial<NarratorSettings>) => {
|
||||
const next = { ...get().narratorSettings, ...partial }
|
||||
set({ narratorSettings: next, narratorEnabled: next.enabled })
|
||||
set({ narratorSettings: { ...get().narratorSettings, ...partial } })
|
||||
},
|
||||
|
||||
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) => {
|
||||
const { wordSpans, direction, activeClue, puzzle } = get()
|
||||
if (!puzzle) return
|
||||
|
||||
Reference in New Issue
Block a user