generated from eblume/project-template
All checks were successful
Build / validate (pull_request) Successful in 6m57s
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side; no schema or sync change (see hub-spoke-data-evolution). - humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa previews instead of the raw FREQ= string. - project.overview RPC + Store::project_overview: each project's parent (via the existing 'parent' links) and direct outstanding-task count, a read-only query. - TUI sidebar: subprojects indented by depth, per-project counts, wider pane, and ListState + scrollbar so it scrolls instead of clipping on overflow. Tests: humanize parity (Rust + JS), round-trip through parse_recurrence, raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle safety. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
895 lines
26 KiB
JavaScript
895 lines
26 KiB
JavaScript
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
|
||
// views and projects, triage tasks, and (the primary use case) capture new
|
||
// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover.
|
||
//
|
||
// Online-only thin client: every action is an RPC to the configured hub (see
|
||
// rpc.js). Context/KB is read-only here (no nvim editing surface).
|
||
|
||
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
|
||
import * as oauth from "./oauth.js";
|
||
import { parse as quickParse } from "./quickadd.js";
|
||
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
||
import {
|
||
ATTENTION_COLORS,
|
||
fmtRelative,
|
||
hasFlag,
|
||
isOverdue,
|
||
nextAttention,
|
||
projectColor,
|
||
} from "./fmt.js";
|
||
|
||
// The built-in views, in the TUI sidebar order (filter.rs BUILTIN_VIEWS).
|
||
const VIEWS = [
|
||
{ id: "tom", title: "Top of Mind" },
|
||
{ id: "tasks", title: "Tasks" },
|
||
{ id: "work", title: "Work Tasks" },
|
||
{ id: "chores", title: "Chores" },
|
||
{ id: "ondeck", title: "On Deck" },
|
||
{ id: "inbox", title: "Inbox" },
|
||
];
|
||
|
||
const state = {
|
||
settings: loadSettings(),
|
||
client: null,
|
||
target: { type: "view", id: "tom", title: "Top of Mind" },
|
||
tasks: [],
|
||
projects: [],
|
||
expandedId: null,
|
||
loading: false,
|
||
error: null,
|
||
search: null, // null, or { query, results }
|
||
lastUndo: null, // { label, run }
|
||
};
|
||
|
||
// Build the RPC client from the current settings, wiring an OIDC silent-refresh
|
||
// hook: on a 401 the client calls this to renew the token (oauth.js) and retry
|
||
// once before surfacing the error.
|
||
function makeClient() {
|
||
return new Client({
|
||
baseUrl: state.settings.baseUrl,
|
||
token: state.settings.token,
|
||
refresh: async () => {
|
||
const tok = await oauth.ensureFreshToken(true);
|
||
applyToken(tok || "");
|
||
return tok;
|
||
},
|
||
});
|
||
}
|
||
|
||
// Adopt `token` as the active bearer: persist it and rebuild the client.
|
||
function applyToken(token) {
|
||
state.settings.token = token || "";
|
||
saveSettings(state.settings);
|
||
state.client = makeClient();
|
||
}
|
||
|
||
state.client = makeClient();
|
||
|
||
// --- tiny DOM helper --------------------------------------------------------
|
||
|
||
/** h("div", {class:"x", onclick:fn}, child, child...) → HTMLElement. */
|
||
function h(tag, props = {}, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(props || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === "class") el.className = v;
|
||
else if (k === "html") el.innerHTML = v;
|
||
else if (k.startsWith("on") && typeof v === "function") {
|
||
el.addEventListener(k.slice(2).toLowerCase(), v);
|
||
} else el.setAttribute(k, v === true ? "" : String(v));
|
||
}
|
||
for (const c of children.flat()) {
|
||
if (c == null || c === false) continue;
|
||
el.append(c.nodeType ? c : document.createTextNode(String(c)));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
|
||
function toast(message, action) {
|
||
const root = $("#toast");
|
||
root.innerHTML = "";
|
||
const node = h(
|
||
"div",
|
||
{ class: "toast-body" },
|
||
h("span", {}, message),
|
||
action &&
|
||
h(
|
||
"button",
|
||
{
|
||
class: "toast-action",
|
||
onclick: () => {
|
||
root.innerHTML = "";
|
||
action.run();
|
||
},
|
||
},
|
||
action.label,
|
||
),
|
||
);
|
||
root.append(node);
|
||
if (!action) setTimeout(() => (root.innerHTML === "" ? null : (root.innerHTML = "")), 2600);
|
||
}
|
||
|
||
// --- data -------------------------------------------------------------------
|
||
|
||
async function reload() {
|
||
if (!state.client.configured) {
|
||
state.error = "Set your hub URL in Settings to begin.";
|
||
render();
|
||
openSettings();
|
||
return;
|
||
}
|
||
state.loading = true;
|
||
state.error = null;
|
||
render();
|
||
try {
|
||
const [tasks, projects] = await Promise.all([
|
||
state.target.type === "view"
|
||
? state.client.view(state.target.id)
|
||
: state.client.list({ scope: [state.target.id] }),
|
||
state.client.projects(),
|
||
]);
|
||
state.tasks = tasks;
|
||
state.projects = projects;
|
||
state.error = null;
|
||
} catch (e) {
|
||
state.error = e instanceof RpcError ? e.message : String(e);
|
||
} finally {
|
||
state.loading = false;
|
||
render();
|
||
}
|
||
}
|
||
|
||
function projectTitle(id) {
|
||
if (!id) return null;
|
||
return state.projects.find((p) => p.id === id)?.title || id;
|
||
}
|
||
|
||
async function refreshProjects() {
|
||
try {
|
||
state.projects = await state.client.projects();
|
||
} catch {
|
||
/* keep stale list */
|
||
}
|
||
}
|
||
|
||
// --- rendering --------------------------------------------------------------
|
||
|
||
function render() {
|
||
renderHeader();
|
||
renderMain();
|
||
}
|
||
|
||
function renderHeader() {
|
||
$("#view-title").textContent = state.search ? "Search" : state.target.title;
|
||
}
|
||
|
||
function attentionDot(att) {
|
||
return h("span", {
|
||
class: "flag",
|
||
style: hasFlag(att) ? `color:${ATTENTION_COLORS[att]}` : "color:transparent",
|
||
}, hasFlag(att) ? "⚑" : "·");
|
||
}
|
||
|
||
function dateChip(t) {
|
||
const now = Date.now();
|
||
if (isOverdue(t.late_on, now)) {
|
||
return h("span", { class: "chip overdue" }, `late ${fmtRelative(t.late_on, now)}`);
|
||
}
|
||
if (t.do_date != null) {
|
||
return h("span", { class: "chip" }, fmtRelative(t.do_date, now));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function taskRow(t) {
|
||
const expanded = state.expandedId === t.node_id;
|
||
const row = h(
|
||
"div",
|
||
{ class: "row" + (expanded ? " expanded" : "") },
|
||
h(
|
||
"div",
|
||
{
|
||
class: "row-head",
|
||
onclick: () => {
|
||
state.expandedId = expanded ? null : t.node_id;
|
||
render();
|
||
if (!expanded) loadPreview(t);
|
||
},
|
||
},
|
||
attentionDot(t.attention),
|
||
h("span", { class: "bullet", style: `color:${projectColor(t.project_id)}` }, "●"),
|
||
h("span", { class: "title" }, t.title),
|
||
t.recurrence && h("span", { class: "recur" }, "↻"),
|
||
dateChip(t),
|
||
),
|
||
expanded && taskDetail(t),
|
||
);
|
||
return row;
|
||
}
|
||
|
||
function taskDetail(t) {
|
||
const meta = [];
|
||
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
|
||
if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]);
|
||
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
|
||
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
|
||
|
||
return h(
|
||
"div",
|
||
{ class: "detail" },
|
||
meta.length &&
|
||
h(
|
||
"div",
|
||
{ class: "meta" },
|
||
meta.map(([k, v]) => h("div", { class: "meta-row" }, h("span", { class: "meta-k" }, k), h("span", {}, v))),
|
||
),
|
||
h(
|
||
"div",
|
||
{ class: "actions" },
|
||
actionBtn("✓ Done", () => triage(t, "done")),
|
||
actionBtn("⤓ Drop", () => triage(t, "dropped")),
|
||
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
|
||
actionBtn("⚑ Attn", () => cycleAttention(t)),
|
||
actionBtn("📅 Date", () => openReschedule(t)),
|
||
actionBtn("📁 Move", () => openMove(t)),
|
||
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
|
||
),
|
||
h("pre", { class: "preview", id: `preview-${t.node_id}` }, "…"),
|
||
);
|
||
}
|
||
|
||
function actionBtn(label, onclick, extra = "") {
|
||
return h("button", { class: `act ${extra}`, onclick }, label);
|
||
}
|
||
|
||
async function loadPreview(t) {
|
||
const pre = $(`#preview-${t.node_id}`);
|
||
if (!pre) return;
|
||
try {
|
||
const ctxId = t.canonical_context_id || (await state.client.contextOf(t.node_id));
|
||
const [body, log] = await Promise.all([
|
||
ctxId ? state.client.nodeBody(ctxId) : Promise.resolve(""),
|
||
state.client.logTail(t.node_id, 5).catch(() => []),
|
||
]);
|
||
const parts = [];
|
||
if (body.trim()) parts.push(body.trim());
|
||
if (log && log.length) parts.push("— log —\n" + log.join("\n"));
|
||
pre.textContent = parts.join("\n\n") || "(no context yet)";
|
||
} catch (e) {
|
||
pre.textContent = `(could not load context: ${e.message})`;
|
||
}
|
||
}
|
||
|
||
function renderMain() {
|
||
const main = $("#main");
|
||
main.innerHTML = "";
|
||
|
||
if (state.search) {
|
||
main.append(searchPane());
|
||
return;
|
||
}
|
||
if (state.error) {
|
||
main.append(h("div", { class: "notice error" }, state.error));
|
||
}
|
||
if (state.loading && state.tasks.length === 0) {
|
||
main.append(h("div", { class: "notice" }, "Loading…"));
|
||
return;
|
||
}
|
||
if (!state.loading && state.tasks.length === 0 && !state.error) {
|
||
main.append(h("div", { class: "notice" }, "Nothing here. Tap + to capture a task."));
|
||
return;
|
||
}
|
||
const list = h("div", { class: "list" }, state.tasks.map(taskRow));
|
||
main.append(list);
|
||
}
|
||
|
||
// --- drawer (views + projects) ---------------------------------------------
|
||
|
||
function renderDrawer() {
|
||
const body = $("#drawer-body");
|
||
body.innerHTML = "";
|
||
body.append(h("div", { class: "drawer-section" }, "Views"));
|
||
for (const v of VIEWS) {
|
||
body.append(drawerItem(v.title, state.target.type === "view" && state.target.id === v.id, () => {
|
||
state.target = { type: "view", id: v.id, title: v.title };
|
||
closeDrawer();
|
||
reload();
|
||
}));
|
||
}
|
||
body.append(h("div", { class: "drawer-section" }, "Projects"));
|
||
if (state.projects.length === 0) {
|
||
body.append(h("div", { class: "drawer-empty" }, "(none yet)"));
|
||
}
|
||
for (const p of state.projects) {
|
||
body.append(drawerItem(p.title, state.target.type === "project" && state.target.id === p.id, () => {
|
||
state.target = { type: "project", id: p.id, title: p.title };
|
||
closeDrawer();
|
||
reload();
|
||
}, projectColor(p.id)));
|
||
}
|
||
}
|
||
|
||
function drawerItem(label, active, onclick, dot) {
|
||
return h(
|
||
"div",
|
||
{ class: "drawer-item" + (active ? " active" : ""), onclick },
|
||
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
|
||
h("span", {}, label),
|
||
);
|
||
}
|
||
|
||
function openDrawer() {
|
||
renderDrawer();
|
||
$("#drawer").classList.add("open");
|
||
$("#backdrop").classList.add("show");
|
||
}
|
||
function closeDrawer() {
|
||
$("#drawer").classList.remove("open");
|
||
$("#backdrop").classList.remove("show");
|
||
}
|
||
|
||
// --- modal scaffolding ------------------------------------------------------
|
||
|
||
function openModal(node) {
|
||
const root = $("#modal-root");
|
||
root.innerHTML = "";
|
||
root.append(h("div", { class: "modal-backdrop", onclick: closeModal }, h("div", { class: "modal", onclick: (e) => e.stopPropagation() }, node)));
|
||
root.classList.add("show");
|
||
}
|
||
function closeModal() {
|
||
$("#modal-root").classList.remove("show");
|
||
$("#modal-root").innerHTML = "";
|
||
}
|
||
function modalOpen() {
|
||
return $("#modal-root").classList.contains("show");
|
||
}
|
||
|
||
// --- quick-add (the primary use case) --------------------------------------
|
||
|
||
function openQuickAdd() {
|
||
closeDrawer();
|
||
const input = h("input", {
|
||
class: "qa-input",
|
||
type: "text",
|
||
placeholder: "Buy milk tomorrow p2 #Work every week",
|
||
autocomplete: "off",
|
||
autocapitalize: "sentences",
|
||
enterkeyhint: "done",
|
||
});
|
||
const preview = h("div", { class: "qa-preview" });
|
||
|
||
const updatePreview = () => {
|
||
const parsed = quickParse(input.value, today(), state.projects);
|
||
preview.innerHTML = "";
|
||
if (!input.value.trim()) {
|
||
preview.append(h("span", { class: "qa-hint" }, "p1–p4 · #Project · today/+3d/fri · every week"));
|
||
return;
|
||
}
|
||
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
|
||
if (parsed.attention) {
|
||
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention));
|
||
}
|
||
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
|
||
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
|
||
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence)));
|
||
};
|
||
input.addEventListener("input", updatePreview);
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
submitQuickAdd(input.value);
|
||
} else if (e.key === "Escape") {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
const mic = voiceButton(input, updatePreview);
|
||
|
||
const node = h(
|
||
"div",
|
||
{ class: "qa" },
|
||
h("div", { class: "qa-row" }, input, mic),
|
||
preview,
|
||
h(
|
||
"div",
|
||
{ class: "qa-foot" },
|
||
state.target.type === "project"
|
||
? h("span", { class: "qa-dest" }, "→ " + state.target.title)
|
||
: h("span", { class: "qa-dest" }, "→ Inbox (unless #Project given)"),
|
||
h("button", { class: "qa-add", onclick: () => submitQuickAdd(input.value) }, "Add"),
|
||
),
|
||
);
|
||
openModal(node);
|
||
updatePreview();
|
||
setTimeout(() => input.focus(), 50);
|
||
}
|
||
|
||
async function submitQuickAdd(raw) {
|
||
const text = raw.trim();
|
||
if (!text) return;
|
||
const parsed = quickParse(text, today(), state.projects);
|
||
if (!parsed.title) {
|
||
toast("Needs a title.");
|
||
return;
|
||
}
|
||
const projectId =
|
||
parsed.projectId || (state.target.type === "project" ? state.target.id : null);
|
||
closeModal();
|
||
try {
|
||
await state.client.createTask({
|
||
title: parsed.title,
|
||
attention: parsed.attention,
|
||
doDate: parsed.doDate,
|
||
recurrence: parsed.recurrence,
|
||
projectId,
|
||
});
|
||
toast(`Added: ${parsed.title}`);
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Add failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// --- voice input ------------------------------------------------------------
|
||
|
||
// Web Speech API where available (desktop Chrome, Android). On iOS Safari the
|
||
// API is absent, but the on-screen keyboard's dictation mic works in the text
|
||
// field for free — so we simply omit the button there.
|
||
function voiceButton(input, onUpdate) {
|
||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (!SR) return null;
|
||
let rec = null;
|
||
const btn = h("button", { class: "qa-mic", title: "Dictate" }, "🎤");
|
||
btn.addEventListener("click", () => {
|
||
if (rec) {
|
||
rec.stop();
|
||
return;
|
||
}
|
||
rec = new SR();
|
||
rec.lang = navigator.language || "en-US";
|
||
rec.interimResults = true;
|
||
btn.classList.add("listening");
|
||
let base = input.value ? input.value + " " : "";
|
||
rec.onresult = (ev) => {
|
||
let text = "";
|
||
for (const r of ev.results) text += r[0].transcript;
|
||
input.value = base + text;
|
||
onUpdate();
|
||
};
|
||
rec.onend = () => {
|
||
rec = null;
|
||
btn.classList.remove("listening");
|
||
input.focus();
|
||
};
|
||
rec.onerror = () => toast("Voice input unavailable.");
|
||
rec.start();
|
||
});
|
||
return btn;
|
||
}
|
||
|
||
// --- reschedule -------------------------------------------------------------
|
||
|
||
function openReschedule(t) {
|
||
const input = h("input", {
|
||
class: "qa-input",
|
||
type: "text",
|
||
placeholder: "today · tomorrow · +3d · fri · 2026-07-01 · (blank to clear)",
|
||
value: t.do_date != null ? fmtRelative(t.do_date) : "",
|
||
autocomplete: "off",
|
||
enterkeyhint: "done",
|
||
});
|
||
const apply = async () => {
|
||
const v = input.value.trim();
|
||
let doDate = null;
|
||
if (v) {
|
||
try {
|
||
doDate = toEpochMs(parseDate(v, today()));
|
||
} catch {
|
||
toast("Unrecognized date.");
|
||
return;
|
||
}
|
||
}
|
||
closeModal();
|
||
try {
|
||
await state.client.setSchedule(t.node_id, { doDate });
|
||
toast(doDate ? `Rescheduled: ${fmtRelative(doDate)}` : "Do-date cleared");
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
};
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") (e.preventDefault(), apply());
|
||
if (e.key === "Escape") closeModal();
|
||
});
|
||
openModal(
|
||
h(
|
||
"div",
|
||
{ class: "qa" },
|
||
h("div", { class: "modal-title" }, "Reschedule"),
|
||
input,
|
||
h("div", { class: "qa-foot" }, h("button", { class: "qa-add", onclick: apply }, "Set")),
|
||
),
|
||
);
|
||
setTimeout(() => input.focus(), 50);
|
||
}
|
||
|
||
// --- move / project picker --------------------------------------------------
|
||
|
||
function openMove(t) {
|
||
const filter = h("input", { class: "qa-input", type: "text", placeholder: "Filter or new project…", autocomplete: "off" });
|
||
const list = h("div", { class: "picker-list" });
|
||
|
||
const renderOptions = () => {
|
||
const q = filter.value.trim().toLowerCase();
|
||
list.innerHTML = "";
|
||
list.append(pickerItem("(Unfile)", () => move(t, null)));
|
||
for (const p of state.projects) {
|
||
if (q && !p.title.toLowerCase().includes(q)) continue;
|
||
list.append(pickerItem(p.title, () => move(t, p.id), projectColor(p.id)));
|
||
}
|
||
const exact = state.projects.some((p) => p.title.toLowerCase() === q);
|
||
if (q && !exact) {
|
||
list.append(pickerItem(`+ New project "${filter.value.trim()}"`, () => createAndMove(t, filter.value.trim())));
|
||
}
|
||
};
|
||
filter.addEventListener("input", renderOptions);
|
||
filter.addEventListener("keydown", (e) => e.key === "Escape" && closeModal());
|
||
|
||
openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Move "${t.title}"`), filter, list));
|
||
renderOptions();
|
||
setTimeout(() => filter.focus(), 50);
|
||
}
|
||
|
||
function pickerItem(label, onclick, dot) {
|
||
return h(
|
||
"div",
|
||
{ class: "picker-item", onclick },
|
||
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
|
||
h("span", {}, label),
|
||
);
|
||
}
|
||
|
||
async function move(t, projectId) {
|
||
closeModal();
|
||
try {
|
||
await state.client.setProject(t.node_id, projectId);
|
||
toast(projectId ? `Moved to ${projectTitle(projectId)}` : "Unfiled");
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function createAndMove(t, name) {
|
||
closeModal();
|
||
try {
|
||
const id = await state.client.createProject(name);
|
||
await refreshProjects();
|
||
await state.client.setProject(t.node_id, id);
|
||
toast(`Moved to ${name}`);
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// --- triage actions ---------------------------------------------------------
|
||
|
||
async function triage(t, newState) {
|
||
state.expandedId = null;
|
||
try {
|
||
await state.client.setState(t.node_id, newState);
|
||
const verb = newState === "done" ? "Done" : "Dropped";
|
||
toast(`${verb}: ${t.title}`, {
|
||
label: "Undo",
|
||
run: async () => {
|
||
try {
|
||
await state.client.setState(t.node_id, "outstanding");
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Undo failed: ${e.message}`);
|
||
}
|
||
},
|
||
});
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function doSkip(t) {
|
||
try {
|
||
await state.client.skip(t.node_id);
|
||
toast(`Skipped: ${t.title}`);
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function cycleAttention(t) {
|
||
const next = nextAttention(t.attention);
|
||
try {
|
||
await state.client.setAttention(t.node_id, next);
|
||
toast(`Attention: ${next}`);
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function doDelete(t) {
|
||
if (!confirm(`Delete "${t.title}"? This removes it for good (use Drop to triage instead).`)) {
|
||
return;
|
||
}
|
||
state.expandedId = null;
|
||
try {
|
||
await state.client.tombstone(t.node_id);
|
||
toast(`Deleted: ${t.title}`);
|
||
reload();
|
||
} catch (e) {
|
||
toast(`Failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// --- search -----------------------------------------------------------------
|
||
|
||
function openSearch() {
|
||
state.search = { query: "", results: [] };
|
||
render();
|
||
setTimeout(() => $("#search-input")?.focus(), 50);
|
||
}
|
||
|
||
function closeSearch() {
|
||
state.search = null;
|
||
render();
|
||
}
|
||
|
||
function searchPane() {
|
||
const input = h("input", {
|
||
id: "search-input",
|
||
class: "search-input",
|
||
type: "search",
|
||
placeholder: "Search tasks & docs…",
|
||
value: state.search.query,
|
||
autocomplete: "off",
|
||
enterkeyhint: "search",
|
||
});
|
||
let timer = null;
|
||
const run = async () => {
|
||
state.search.query = input.value;
|
||
const q = input.value.trim();
|
||
if (!q) {
|
||
state.search.results = [];
|
||
renderSearchResults();
|
||
return;
|
||
}
|
||
try {
|
||
state.search.results = await state.client.search(q);
|
||
} catch (e) {
|
||
state.search.results = [];
|
||
toast(e.message);
|
||
}
|
||
renderSearchResults();
|
||
};
|
||
input.addEventListener("input", () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(run, 200);
|
||
});
|
||
input.addEventListener("keydown", (e) => e.key === "Escape" && closeSearch());
|
||
|
||
return h(
|
||
"div",
|
||
{ class: "search-pane" },
|
||
h("div", { class: "search-bar" }, input, h("button", { class: "search-close", onclick: closeSearch }, "✕")),
|
||
h("div", { class: "search-results", id: "search-results" }),
|
||
);
|
||
}
|
||
|
||
function renderSearchResults() {
|
||
const root = $("#search-results");
|
||
if (!root) return;
|
||
root.innerHTML = "";
|
||
if (!state.search.results.length) {
|
||
root.append(h("div", { class: "notice" }, state.search.query ? "No matches." : "Type to search."));
|
||
return;
|
||
}
|
||
for (const hit of state.search.results) {
|
||
root.append(
|
||
h(
|
||
"div",
|
||
{ class: "search-hit" },
|
||
h("span", { class: "hit-kind" }, `[${hit.kind}]`),
|
||
h("span", {}, hit.title),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- settings ---------------------------------------------------------------
|
||
|
||
function openSettings() {
|
||
const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" });
|
||
const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" });
|
||
const test = h("div", { class: "settings-test" });
|
||
const setTest = (msg, ok) => {
|
||
test.textContent = msg;
|
||
test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : "");
|
||
};
|
||
|
||
const save = async () => {
|
||
state.settings.baseUrl = url.value.trim();
|
||
state.settings.token = tok.value.trim();
|
||
saveSettings(state.settings);
|
||
state.client = makeClient();
|
||
closeModal();
|
||
reload();
|
||
};
|
||
const check = async () => {
|
||
setTest("Checking…", null);
|
||
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
|
||
try {
|
||
const v = await probe.call("version", {});
|
||
setTest(`✓ Connected (hephd ${v.version})`, true);
|
||
} catch (e) {
|
||
setTest(`✗ ${e.message}`, false);
|
||
}
|
||
};
|
||
// Login with Authentik: read the hub's /config for the issuer + client id,
|
||
// then start the PKCE redirect (this navigates away and returns to init()).
|
||
const login = async () => {
|
||
const hub = url.value.trim() || state.settings.baseUrl;
|
||
if (!hub) return setTest("✗ Set the hub URL first.", false);
|
||
setTest("Contacting hub…", null);
|
||
const cfg = await oauth.fetchHubConfig(hub);
|
||
if (!cfg) {
|
||
return setTest("✗ Hub has no OIDC (/config) — paste a token, or enable OIDC on the hub.", false);
|
||
}
|
||
state.settings.baseUrl = hub;
|
||
saveSettings(state.settings); // persist before we navigate away
|
||
try {
|
||
await oauth.beginLogin(cfg);
|
||
} catch (e) {
|
||
setTest(`✗ ${e.message}`, false);
|
||
}
|
||
};
|
||
const logout = () => {
|
||
oauth.clearAuth();
|
||
applyToken("");
|
||
closeModal();
|
||
reload();
|
||
};
|
||
|
||
const authRow = oauth.loggedIn()
|
||
? h(
|
||
"div",
|
||
{ class: "settings-auth" },
|
||
h("span", { class: "settings-test ok" }, "✓ Signed in with Authentik"),
|
||
h("button", { class: "act", onclick: logout }, "Log out"),
|
||
)
|
||
: h("button", { class: "qa-add settings-login", onclick: login }, "Login with Authentik");
|
||
|
||
openModal(
|
||
h(
|
||
"div",
|
||
{ class: "qa" },
|
||
h("div", { class: "modal-title" }, "Settings"),
|
||
h("label", { class: "settings-label" }, "Hub URL"),
|
||
url,
|
||
h("label", { class: "settings-label" }, "Sign-in"),
|
||
authRow,
|
||
h(
|
||
"details",
|
||
{ class: "settings-manual" },
|
||
h("summary", {}, "Or paste a bearer token"),
|
||
tok,
|
||
),
|
||
test,
|
||
h(
|
||
"div",
|
||
{ class: "qa-foot settings-foot" },
|
||
h("button", { class: "act", onclick: check }, "Test"),
|
||
h("button", { class: "qa-add", onclick: save }, "Save"),
|
||
),
|
||
h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."),
|
||
),
|
||
);
|
||
}
|
||
|
||
// --- keyboard ---------------------------------------------------------------
|
||
|
||
function onKeydown(e) {
|
||
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
|
||
// Cmd/Ctrl + ' opens quick-add anywhere (mirrors the global popover).
|
||
if ((e.metaKey || e.ctrlKey) && e.key === "'") {
|
||
e.preventDefault();
|
||
openQuickAdd();
|
||
return;
|
||
}
|
||
if (typing || modalOpen()) {
|
||
if (e.key === "Escape" && modalOpen()) closeModal();
|
||
return;
|
||
}
|
||
if (e.key === "a") (e.preventDefault(), openQuickAdd());
|
||
else if (e.key === "/") (e.preventDefault(), openSearch());
|
||
else if (e.key === "r") reload();
|
||
else if (e.key === "Escape") state.search ? closeSearch() : closeDrawer();
|
||
}
|
||
|
||
// --- shell + init -----------------------------------------------------------
|
||
|
||
function buildShell() {
|
||
const app = $("#app");
|
||
app.append(
|
||
h(
|
||
"header",
|
||
{ class: "appbar" },
|
||
h("button", { class: "icon-btn", title: "Menu", onclick: openDrawer }, "☰"),
|
||
h("div", { id: "view-title", class: "appbar-title" }, state.target.title),
|
||
h("button", { class: "icon-btn", title: "Search", onclick: openSearch }, "🔍"),
|
||
h("button", { class: "icon-btn", title: "Settings", onclick: openSettings }, "⚙"),
|
||
),
|
||
h("main", { id: "main" }),
|
||
h("button", { id: "fab", class: "fab", title: "Quick add (Cmd-’)", onclick: openQuickAdd }, "+"),
|
||
h("div", { id: "backdrop", class: "backdrop", onclick: closeDrawer }),
|
||
h(
|
||
"aside",
|
||
{ id: "drawer", class: "drawer" },
|
||
h("div", { class: "drawer-head" }, "heph"),
|
||
h("div", { id: "drawer-body", class: "drawer-body" }),
|
||
),
|
||
h("div", { id: "modal-root", class: "modal-root" }),
|
||
h("div", { id: "toast", class: "toast" }),
|
||
);
|
||
}
|
||
|
||
async function init() {
|
||
buildShell();
|
||
document.addEventListener("keydown", onKeydown);
|
||
|
||
// The PWA shares the daemon's store with the TUI / desktop popover, but only
|
||
// re-fetches on a view switch or an action. So another surface marking a task
|
||
// done leaves a stale list on screen until then. Re-fetch the current view
|
||
// whenever the app regains focus (switching back to the phone, unlock, tab
|
||
// re-show) — but not while a modal or search is mid-interaction.
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (
|
||
document.visibilityState === "visible" &&
|
||
state.client.configured &&
|
||
!modalOpen() &&
|
||
!state.search
|
||
) {
|
||
reload();
|
||
}
|
||
});
|
||
|
||
// OIDC: finish a redirect callback (back from Authentik), or refresh an
|
||
// existing session, so the first reload() already carries a valid bearer.
|
||
if (oauth.isCallback()) {
|
||
try {
|
||
applyToken(await oauth.completeLogin());
|
||
toast("Signed in.");
|
||
} catch (e) {
|
||
toast(`Sign-in failed: ${e.message}`);
|
||
}
|
||
} else if (oauth.loggedIn()) {
|
||
const tok = await oauth.ensureFreshToken();
|
||
if (tok) applyToken(tok);
|
||
}
|
||
|
||
render();
|
||
reload();
|
||
|
||
if ("serviceWorker" in navigator) {
|
||
try {
|
||
await navigator.serviceWorker.register("./sw.js");
|
||
} catch {
|
||
/* offline shell is best-effort */
|
||
}
|
||
}
|
||
}
|
||
|
||
init();
|