diff --git a/README.md b/README.md index f1eba62..4d94877 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o - Single-page, zero-build web app - Tasks persist in `localStorage` by default, with optional GitHub sign-in sync - GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie +- English and Spanish UI copy with browser-language detection and a saved manual override - Keyboard-friendly (more shortcuts coming, see #5) - Light & dark themes (dark coming, see #1) - MIT licensed diff --git a/index.html b/index.html index b6b00fb..8d29f88 100644 --- a/index.html +++ b/index.html @@ -1,23 +1,37 @@ - + - TaskForge — open-source task tracker + TaskForge - open-source task tracker

TaskForge

-

A tiny task list that lives in your browser.

+

+ A tiny task list that lives in your browser. +

+
- + Tasks are stored on this device. -
@@ -26,21 +40,27 @@

TaskForge

-
+ - +
-
diff --git a/src/app.js b/src/app.js index 46dd724..cfa5c75 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,7 @@ import { logout, startGitHubSignIn, } from "./auth.js"; +import { getInitialLocale, saveLocale, translate } from "./i18n.mjs"; import { load, loadLocal, save } from "./storage.js"; import { createTask, toggleTask, removeTask } from "./tasks.js"; @@ -15,11 +16,15 @@ const authStatus = document.getElementById("auth-status"); const authAction = document.getElementById("auth-action"); const authAvatar = document.getElementById("auth-avatar"); const authMessage = document.getElementById("auth-message"); +const localeSelect = document.getElementById("locale-select"); let tasks = []; let user = null; +let locale = getInitialLocale(); async function init() { + localeSelect.value = locale; + applyTranslations(); const localTasks = loadLocal(); let completedSignIn = false; try { @@ -72,8 +77,8 @@ function render() { const del = document.createElement("button"); del.type = "button"; del.className = "task-delete"; - del.textContent = "✕"; - del.setAttribute("aria-label", `Delete task: ${task.title}`); + del.textContent = "x"; + del.setAttribute("aria-label", t("deleteTaskAria", { title: task.title })); del.addEventListener("click", async () => { tasks = removeTask(tasks, task.id); await save(tasks, user); @@ -114,25 +119,52 @@ authAction.addEventListener("click", async () => { } }); +localeSelect.addEventListener("change", () => { + locale = localeSelect.value; + saveLocale(locale); + applyTranslations(); + renderAuth(); + render(); +}); + function renderAuth() { authAction.disabled = false; if (!user) { authAvatar.hidden = true; authAvatar.removeAttribute("src"); authAvatar.removeAttribute("alt"); - authStatus.textContent = "Tasks are stored on this device."; - authAction.textContent = "Sign in with GitHub"; + authStatus.textContent = t("authLocal"); + authAction.textContent = t("signIn"); return; } authAvatar.hidden = false; authAvatar.src = user.avatarUrl; authAvatar.alt = `${user.login}'s avatar`; - authStatus.textContent = `Signed in as ${user.login}`; - authAction.textContent = "Logout"; + authStatus.textContent = t("signedInAs", { login: user.login }); + authAction.textContent = t("logout"); setAuthMessage(""); } +function applyTranslations() { + document.documentElement.lang = locale; + document.title = t("documentTitle"); + + for (const element of document.querySelectorAll("[data-i18n]")) { + element.textContent = t(element.dataset.i18n); + } + for (const element of document.querySelectorAll("[data-i18n-placeholder]")) { + element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder)); + } + for (const element of document.querySelectorAll("[data-i18n-aria-label]")) { + element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel)); + } +} + +function t(key, values) { + return translate(locale, key, values); +} + function setAuthMessage(message) { authMessage.textContent = message; authMessage.hidden = !message; diff --git a/src/i18n.mjs b/src/i18n.mjs new file mode 100644 index 0000000..2c17268 --- /dev/null +++ b/src/i18n.mjs @@ -0,0 +1,74 @@ +const STORAGE_KEY = "taskforge.locale"; + +const messages = { + en: { + addButton: "Add", + authLocal: "Tasks are stored on this device.", + deleteTaskAria: "Delete task: {title}", + documentTitle: "TaskForge - open-source task tracker", + emptyState: "No tasks yet. Add one above to get started.", + languageLabel: "Language", + logout: "Logout", + newTaskAria: "Add a new task", + signedInAs: "Signed in as {login}", + signIn: "Sign in with GitHub", + tagline: "A tiny task list that lives in your browser.", + taskPlaceholder: "What needs doing?", + }, + es: { + addButton: "Agregar", + authLocal: "Las tareas se guardan en este dispositivo.", + deleteTaskAria: "Eliminar tarea: {title}", + documentTitle: "TaskForge - gestor de tareas open source", + emptyState: "Todavia no hay tareas. Agrega una arriba para empezar.", + languageLabel: "Idioma", + logout: "Cerrar sesion", + newTaskAria: "Agregar una nueva tarea", + signedInAs: "Sesion iniciada como {login}", + signIn: "Iniciar sesion con GitHub", + tagline: "Una lista de tareas pequena que vive en tu navegador.", + taskPlaceholder: "Que hay que hacer?", + }, +}; + +export const SUPPORTED_LOCALES = Object.freeze(Object.keys(messages)); + +export function getInitialLocale({ + navigatorLanguage = globalThis.navigator?.language, + storage = globalThis.localStorage, +} = {}) { + const saved = readSavedLocale(storage); + if (saved) return saved; + return normalizeLocale(navigatorLanguage); +} + +export function normalizeLocale(locale) { + const normalized = String(locale || "") + .trim() + .toLowerCase(); + if (normalized.startsWith("es")) return "es"; + return "en"; +} + +export function readSavedLocale(storage = globalThis.localStorage) { + try { + const saved = storage?.getItem(STORAGE_KEY); + return SUPPORTED_LOCALES.includes(saved) ? saved : ""; + } catch { + return ""; + } +} + +export function saveLocale(locale, storage = globalThis.localStorage) { + if (!SUPPORTED_LOCALES.includes(locale)) return; + try { + storage?.setItem(STORAGE_KEY, locale); + } catch { + // Ignore storage failures so language switching remains non-blocking. + } +} + +export function translate(locale, key, values = {}) { + const template = messages[locale]?.[key] || messages.en[key] || key; + return template.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ""); +} diff --git a/styles.css b/styles.css index a6c7afc..9c99fd2 100644 --- a/styles.css +++ b/styles.css @@ -15,8 +15,8 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, - sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; @@ -43,6 +43,24 @@ body { font-size: 0.95rem; } +.locale-control { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--muted); + font-size: 0.9rem; + white-space: nowrap; +} + +.locale-control select { + padding: 0.4rem 0.55rem; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + .auth-panel { display: flex; align-items: center; @@ -204,6 +222,10 @@ body { width: 100%; } + .locale-control { + width: 100%; + } + .auth-status { flex: 1; max-width: none; diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs new file mode 100644 index 0000000..2019f59 --- /dev/null +++ b/test/i18n.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getInitialLocale, + normalizeLocale, + saveLocale, + translate, +} from "../src/i18n.mjs"; + +test("normalizes supported browser languages", () => { + assert.equal(normalizeLocale("es-MX"), "es"); + assert.equal(normalizeLocale("ES"), "es"); + assert.equal(normalizeLocale("fr-FR"), "en"); + assert.equal(normalizeLocale(""), "en"); +}); + +test("saved locale overrides browser detection", () => { + const storage = memoryStorage({ "taskforge.locale": "es" }); + assert.equal(getInitialLocale({ navigatorLanguage: "en-US", storage }), "es"); +}); + +test("invalid saved locale falls back to navigator language", () => { + const storage = memoryStorage({ "taskforge.locale": "fr" }); + assert.equal(getInitialLocale({ navigatorLanguage: "es-ES", storage }), "es"); +}); + +test("translation interpolates dynamic values", () => { + assert.equal( + translate("es", "signedInAs", { login: "octo" }), + "Sesion iniciada como octo", + ); + assert.equal( + translate("en", "deleteTaskAria", { title: "Ship" }), + "Delete task: Ship", + ); +}); + +test("saveLocale ignores unsupported locales", () => { + const storage = memoryStorage(); + saveLocale("fr", storage); + assert.equal(storage.getItem("taskforge.locale"), null); + saveLocale("es", storage); + assert.equal(storage.getItem("taskforge.locale"), "es"); +}); + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +}