Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const browserGlobals = {
btoa: "readonly",
crypto: "readonly",
document: "readonly",
fetch: "readonly",
localStorage: "readonly",
sessionStorage: "readonly",
TextEncoder: "readonly",
URL: "readonly",
URLSearchParams: "readonly",
Expand All @@ -11,19 +14,27 @@ const browserGlobals = {
const nodeGlobals = {
Buffer: "readonly",
console: "readonly",
module: "readonly",
fetch: "readonly",
process: "readonly",
require: "readonly",
__dirname: "readonly",
};

const testGlobals = {
...nodeGlobals,
Map: "readonly",
Set: "readonly",
};

export default [
{
files: ["server.js", "test/**/*.js"],
languageOptions: {
ecmaVersion: 2023,
sourceType: "commonjs",
globals: nodeGlobals,
sourceType: "module",
globals: testGlobals,
},
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
semi: ["error", "always"],
},
},
{
Expand All @@ -33,5 +44,9 @@ export default [
sourceType: "module",
globals: browserGlobals,
},
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
semi: ["error", "always"],
},
},
];
48 changes: 40 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskForge — open-source task tracker</title>
<script>
(() => {
const key = "taskforge.theme";
let theme;
try {
theme = localStorage.getItem(key);
} catch {
theme = null;
}
const prefersDark = window.matchMedia?.(
"(prefers-color-scheme: dark)",
).matches;
const resolved =
theme === "light" || theme === "dark"
? theme
: prefersDark
? "dark"
: "light";
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
})();
</script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
Expand All @@ -12,13 +34,23 @@
<h1>TaskForge</h1>
<p class="tagline">A tiny task list that lives in your browser.</p>
</div>
<div class="auth-panel" aria-live="polite">
<img id="auth-avatar" class="auth-avatar" hidden />
<span id="auth-status" class="auth-status">
Tasks are stored on this device.
</span>
<button id="auth-action" class="auth-action" type="button">
Sign in with GitHub
<div class="header-actions">
<div class="auth-panel" aria-live="polite">
<img id="auth-avatar" class="auth-avatar" hidden />
<span id="auth-status" class="auth-status">
Tasks are stored on this device.
</span>
<button id="auth-action" class="auth-action" type="button">
Sign in with GitHub
</button>
</div>
<button
id="theme-toggle"
class="theme-toggle"
type="button"
aria-pressed="false"
>
Dark mode
</button>
</div>
</header>
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"version": "0.1.0",
"description": "A simple, community-maintained open-source task tracker.",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint server.js src test",
"test": "node --test",
"format": "prettier --write 'src/**/*.js' 'index.html' 'styles.css'",
"format": "prettier --write 'src/**/*.js' 'test/**/*.js' 'index.html' 'styles.css' 'eslint.config.mjs'",
"start": "node server.js"
},
"devDependencies": {
Expand Down
24 changes: 12 additions & 12 deletions server.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const http = require("node:http");
const path = require("node:path");
import crypto from "node:crypto";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const HOST = process.env.HOST || "127.0.0.1";
const PORT = Number(process.env.PORT || 8000);
Expand All @@ -22,7 +26,7 @@ const CONTENT_TYPES = {

const sessions = new Map();

function createServer(options = {}) {
export function createServer(options = {}) {
const dataFile = options.dataFile || DATA_FILE;
const fetchImpl = options.fetchImpl || global.fetch;
const sessionStore = options.sessionStore || sessions;
Expand Down Expand Up @@ -254,7 +258,7 @@ async function readJson(req) {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

function sanitizeTasks(tasks) {
export function sanitizeTasks(tasks) {
if (!Array.isArray(tasks)) return [];
return tasks
.filter((task) => task && typeof task === "object")
Expand Down Expand Up @@ -311,14 +315,10 @@ function sendText(res, statusCode, text) {
res.end(text);
}

if (require.main === module) {
if (process.argv[1] === __filename) {
createServer().listen(PORT, HOST, () => {
console.log(`TaskForge listening at http://${HOST}:${PORT}`);
});
}

module.exports = {
SESSION_COOKIE,
createServer,
sanitizeTasks,
};
export { SESSION_COOKIE };
4 changes: 4 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
} from "./auth.js";
import { load, loadLocal, save } from "./storage.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";
import { initThemeToggle } from "./theme.js";

const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
const list = document.getElementById("task-list");
const emptyState = document.getElementById("empty-state");
const themeToggle = document.getElementById("theme-toggle");
const authStatus = document.getElementById("auth-status");
const authAction = document.getElementById("auth-action");
const authAvatar = document.getElementById("auth-avatar");
Expand Down Expand Up @@ -44,6 +46,8 @@ async function init() {
render();
}

initThemeToggle({ button: themeToggle });

function render() {
list.innerHTML = "";
if (tasks.length === 0) {
Expand Down
3 changes: 2 additions & 1 deletion src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export async function startGitHubSignIn() {
const codeVerifier = randomString(64);
const codeChallenge = await pkceChallenge(codeVerifier);
const redirectUri =
config.redirectUri || `${window.location.origin}${window.location.pathname}`;
config.redirectUri ||
`${window.location.origin}${window.location.pathname}`;

window.sessionStorage.setItem(AUTH_STATE_KEY, state);
window.sessionStorage.setItem(CODE_VERIFIER_KEY, codeVerifier);
Expand Down
4 changes: 3 additions & 1 deletion src/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ function todayStamp() {
}

export function exportJson(tasks) {
const blob = new Blob([JSON.stringify(tasks, null, 2)], { type: "application/json" });
const blob = new Blob([JSON.stringify(tasks, null, 2)], {
type: "application/json",
});
download(blob, `taskforge-export-${todayStamp()}.json`);
}

Expand Down
81 changes: 81 additions & 0 deletions src/theme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const THEME_KEY = "taskforge.theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";
const THEMES = new Set(["light", "dark"]);

export function getStoredTheme(storage = window.localStorage) {
try {
const value = storage.getItem(THEME_KEY);
return THEMES.has(value) ? value : null;
} catch {
return null;
}
}

export function getSystemTheme(matchMedia = window.matchMedia) {
if (typeof matchMedia !== "function") {
return "light";
}
return matchMedia(DARK_QUERY).matches ? "dark" : "light";
}

export function resolveTheme({
storage = window.localStorage,
matchMedia = window.matchMedia,
} = {}) {
return getStoredTheme(storage) ?? getSystemTheme(matchMedia);
}

export function applyTheme(theme, root = document.documentElement) {
const nextTheme = THEMES.has(theme) ? theme : "light";
root.dataset.theme = nextTheme;
root.style.colorScheme = nextTheme;
return nextTheme;
}

export function saveTheme(theme, storage = window.localStorage) {
if (!THEMES.has(theme)) return;
try {
storage.setItem(THEME_KEY, theme);
} catch {
// Theme persistence is progressive enhancement; keep the UI usable.
}
}

export function getNextTheme(currentTheme) {
return currentTheme === "dark" ? "light" : "dark";
}

export function initThemeToggle({
button,
storage = window.localStorage,
matchMedia = window.matchMedia,
root = document.documentElement,
} = {}) {
let theme = applyTheme(resolveTheme({ storage, matchMedia }), root);

const renderButton = () => {
button.textContent = theme === "dark" ? "Light mode" : "Dark mode";
button.setAttribute("aria-pressed", String(theme === "dark"));
button.setAttribute(
"aria-label",
theme === "dark" ? "Switch to light mode" : "Switch to dark mode",
);
};

renderButton();

button.addEventListener("click", () => {
theme = applyTheme(getNextTheme(theme), root);
saveTheme(theme, storage);
renderButton();
});

if (!getStoredTheme(storage) && typeof matchMedia === "function") {
const media = matchMedia(DARK_QUERY);
media.addEventListener?.("change", (event) => {
if (getStoredTheme(storage)) return;
theme = applyTheme(event.matches ? "dark" : "light", root);
renderButton();
});
}
}
Loading