kanban-todo.txt/index.html
2026-01-03 21:14:52 +08:00

2239 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban TODO</title>
<style>
:root {
--fg: #ffe;
--bg: #112;
--shade: #889;
--radius: 4px;
}
html {
font-size: 1.25rem;
box-sizing: border-box;
}
*, ::after, ::before {
box-sizing: inherit;
text-decoration-thickness: .1rem;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: Helvetica, Arial, sans-serif;
padding: 1em;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
a {
color: var(--fg);
}
button {
display: inline-block;
vertical-align: middle;
padding: .4rem .8rem;
font-size: 1rem;
font-weight: normal;
background: var(--fg);
color: var(--bg);
border: .1rem solid var(--fg);
border-radius: var(--radius);
cursor: pointer;
}
button:hover {
opacity: 0.9;
}
button:disabled {
cursor: not-allowed;
background: var(--shade);
border: .1rem solid var(--shade);
}
input[type="text"] {
appearance: none;
box-shadow: none;
box-sizing: inherit;
padding: .5rem .5rem;
width: 100%;
font-size: 1rem;
color: var(--fg);
background-color: var(--bg);
border: .1rem solid var(--fg);
border-radius: var(--radius);
font-family: Helvetica, Arial, sans-serif;
}
input::placeholder {
color: var(--fg);
font-style: italic;
opacity: 1;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; margin-bottom: 0.5em; }
h3 { font-size: 1.17em; }
h1, h2, h3 { margin-top: 0; }
#app {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.screen {
display: none;
flex-direction: column;
height: 100%;
width: 100%;
}
.screen.active {
display: flex;
}
.center-content {
overflow-y: auto;
align-items: center;
text-align: center;
gap: 1.5em;
padding: 2em 0;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1em;
border-bottom: .1rem solid var(--shade);
margin-bottom: 1em;
flex-shrink: 0;
}
.header-controls {
display: flex;
gap: 0.5em;
align-items: center;
position: relative;
}
.burger-btn {
width: 40px;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
}
.burger-btn:hover {
background: var(--shade);
opacity: 1;
}
.burger-btn svg {
width: 24px;
height: 24px;
stroke: var(--fg);
}
.burger-menu {
position: absolute;
right: 0;
top: 50px;
background: var(--bg);
border: .1rem solid var(--fg);
border-radius: var(--radius);
padding: 0.5em 0;
min-width: 250px;
z-index: 100;
display: none;
}
.burger-menu.active {
display: block;
}
.burger-menu-item {
padding: 0.75em 1em;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75em;
background: transparent;
border: none;
color: var(--fg);
width: 100%;
text-align: left;
font-size: 1rem;
}
.burger-menu-item:hover {
background: var(--shade);
opacity: 1;
}
.burger-menu-checkbox {
width: 1.2em;
height: 1.2em;
border: .1rem solid var(--fg);
border-radius: 2px;
background: transparent;
flex-shrink: 0;
}
.burger-menu-checkbox.checked {
background: var(--fg);
}
.board {
display: flex;
gap: 1em;
overflow-x: auto;
flex-grow: 1;
align-items: stretch;
padding-bottom: 1em;
}
.column {
background: transparent;
border-radius: var(--radius);
min-width: 200px;
max-width: 450px;
flex: 1 1 0;
display: flex;
flex-direction: column;
height: 100%;
border: .1rem solid var(--shade);
}
.column-header {
padding: 0.75em;
font-weight: bold;
border-bottom: .1rem solid var(--shade);
flex-shrink: 0;
}
.card-list {
padding: 0.5em;
overflow-y: auto;
overflow-x: hidden;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.5em;
min-height: 0;
position: relative;
}
.card {
background: transparent;
padding: 0.75em;
border-radius: var(--radius);
border: .1rem solid var(--shade);
cursor: grab;
user-select: none;
word-wrap: break-word;
position: relative;
flex-shrink: 0;
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.card:hover {
border-color: var(--fg);
}
.card:active {
cursor: grabbing;
}
.card.completed {
text-decoration: line-through;
opacity: 0.6;
}
.card.dragging {
opacity: 0.5;
border: .1rem dashed var(--fg);
}
.card-text {
flex-grow: 1;
}
.card-expand-indicator {
color: var(--shade);
font-size: 1.2em;
line-height: 1;
margin-left: 0.25em;
}
.card-menu-btn {
width: 1.5em;
height: 1.5em;
padding: 0;
background: transparent;
border: none;
color: var(--shade);
cursor: pointer;
font-size: 1.2em;
line-height: 1;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.card-menu-btn:hover {
color: var(--fg);
opacity: 1;
}
.card-menu {
position: absolute;
right: 0;
top: 2em;
background: var(--bg);
border: .1rem solid var(--fg);
border-radius: var(--radius);
padding: 0.5em 0;
min-width: 150px;
z-index: 100;
display: none;
}
.card-menu.active {
display: block;
}
.card-menu-item {
padding: 0.5em 1em;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5em;
background: transparent;
border: none;
color: var(--fg);
width: 100%;
text-align: left;
font-size: 0.9rem;
}
.card-menu-item:hover {
background: var(--shade);
opacity: 1;
}
.card-menu-checkbox {
width: 1em;
height: 1em;
border: .1rem solid var(--fg);
border-radius: 2px;
background: transparent;
flex-shrink: 0;
}
.card-menu-checkbox.checked {
background: var(--fg);
}
.drop-indicator {
height: 3px;
background: var(--fg);
border-radius: 2px;
opacity: 0;
transition: opacity 0.1s;
pointer-events: none;
flex-shrink: 0;
}
.drop-indicator.active {
opacity: 1;
}
.add-column {
min-width: 60px;
max-width: 60px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 0.75em;
}
.add-column-btn {
width: 40px;
height: 40px;
font-size: 1.5rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.add-column-form {
display: none;
flex-direction: column;
gap: 0.5em;
width: 200px;
padding: 0.5em;
background: var(--bg);
border: .1rem solid var(--shade);
border-radius: var(--radius);
position: absolute;
right: 70px;
top: 0;
z-index: 10;
}
.add-column-form.active {
display: flex;
}
.add-column-form input {
margin: 0;
}
.add-column-form-buttons {
display: flex;
gap: 0.5em;
}
.add-column-form-buttons button {
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.9rem;
}
.column-footer {
padding: 0.5em;
display: flex;
flex-direction: column;
gap: 0.5em;
align-items: flex-end;
flex-shrink: 0;
}
.add-card-btn {
width: 40px;
height: 40px;
font-size: 1.5rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.add-card-form {
display: none;
flex-direction: column;
gap: 0.5em;
width: 100%;
}
.add-card-form.active {
display: flex;
}
.add-card-form input {
margin: 0;
}
.add-card-form-buttons {
display: flex;
gap: 0.5em;
}
.add-card-form-buttons button {
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.9rem;
}
.section-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 600px;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.section-btn {
width: 100%;
text-align: left;
padding: 1em;
background: transparent;
color: var(--fg);
border: .1rem solid var(--shade);
}
.section-btn:hover {
border-color: var(--fg);
}
.new-project-form {
width: 100%;
max-width: 600px;
display: flex;
gap: 0.5em;
margin-top: 1em;
}
.new-project-form input {
flex-grow: 1;
}
.project-name-form {
width: 100%;
max-width: 600px;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.project-name-form label {
text-align: left;
font-weight: bold;
}
.project-name-form .form-row {
display: flex;
gap: 0.5em;
}
.project-name-form input {
flex-grow: 1;
}
.recent-projects {
width: 100%;
max-width: 600px;
margin-top: 2em;
}
.recent-projects h3 {
text-align: left;
margin-bottom: 0.5em;
}
.recent-projects-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.recent-project-item {
display: flex;
align-items: center;
gap: 0.5em;
}
.recent-project-btn {
flex-grow: 1;
text-align: left;
padding: 0.75em;
background: transparent;
color: var(--fg);
border: .1rem solid var(--shade);
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5em;
}
.recent-project-btn:hover {
border-color: var(--fg);
}
.recent-project-btn.unavailable {
opacity: 0.5;
cursor: default;
}
.recent-project-btn.unavailable:hover {
border-color: var(--shade);
opacity: 0.5;
}
.recent-project-warning {
color: #f80;
font-size: 1.2em;
}
.remove-project-btn {
padding: 0.5em;
font-size: 0.8rem;
min-width: auto;
background: transparent;
color: var(--shade);
border: .1rem solid var(--shade);
}
.remove-project-btn:hover {
color: var(--fg);
border-color: var(--fg);
opacity: 1;
}
.error {
background-color: #782828;
padding: 0.5em 1em;
border-radius: var(--radius);
}
.modal {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: none;
justify-content: center;
align-items: center;
background-color: rgba(0,0,0,0.5);
}
.modal.active {
display: flex;
}
.modal-content {
background-color: var(--bg);
border: .1rem solid var(--fg);
border-radius: var(--radius);
padding: 2em;
max-width: 90%;
max-height: 90%;
overflow-y: auto;
min-width: 400px;
width: 1024px;
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 1em;
}
.modal-section {
margin-bottom: 1.5em;
}
.modal-section label {
display: block;
font-weight: bold;
margin-bottom: 0.5em;
}
.modal-buttons {
display: flex;
gap: 0.5em;
justify-content: flex-end;
margin-top: 1.5em;
}
.project-select, .column-select {
width: 100%;
padding: 0.5em;
padding-right: 2.5em;
background: var(--bg);
color: var(--fg);
border: .1rem solid var(--fg);
border-radius: var(--radius);
font-size: 1rem;
font-family: Helvetica, Arial, sans-serif;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8"><path d="M1 1 L6 6 L11 1" stroke="%23ffe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>');
background-repeat: no-repeat;
background-position: right 0.75em center;
background-size: 12px 8px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--shade);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--fg);
}
hr {
border: .05rem solid var(--fg);
}
p {
line-height: 1.4;
}
pre {
background: transparent;
border: .1rem solid var(--shade);
border-radius: var(--radius);
padding: 1em;
margin: 0.5em 0 1em 0;
overflow-x: auto;
text-align: left;
}
pre a {
text-decoration: underline;
}
code {
background: transparent;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
}
ul {
list-style: square;
}
.card-detail-content {
white-space: pre-wrap;
font-family: monospace;
text-align: left;
line-height: 1.6;
}
</style>
</head>
<body>
<template id="install-instructions-template">
<p>Install launch script:</p>
<pre>curl -O <a href="https://mccormick.cx/apps/kanban-todo/kbtd">https://mccormick.cx/apps/kanban-todo/kbtd</a>
chmod 755 kbtd
# If self-hosting, set KBTD_URL to point at your URL:
# export KBTD_URL="<span class="current-url">https://mccormick.cx/apps/kanban-todo/kbtd</span>"
# Then launch with:
./kbtd TODO.md</pre>
</template>
<div id="app">
<div id="landing-screen" class="screen center-content">
<h1>Kanban TODO</h1>
<p>A Kanban board for your Markdown TODO file.</p>
<ul style="text-align: left; max-width: 600px;">
<li>Drag-and-drop visual editing of plain text Markdown</li>
<li>Runs 100% in your browser - no server, no database</li>
<li>Single HTML file - upload to self-host</li>
<li>CLI tool to launch as a Chrome app: <code>kbtd TODO.md</code></li>
<li>Two-way binding between UI and text file</li>
</ul>
<div class="project-name-form">
<label for="project-name-input">Project name:</label>
<div class="form-row">
<input type="text" id="project-name-input" placeholder="e.g. TODO, work, personal...">
<button id="open-file-btn">Open TODO.md</button>
</div>
</div>
<div id="recent-projects" class="recent-projects" style="display:none">
<h3>Recent projects:</h3>
<ul id="recent-projects-list" class="recent-projects-list"></ul>
</div>
<div id="landing-instructions"></div>
<div id="landing-error" class="error" style="display:none"></div>
</div>
<div id="permission-screen" class="screen center-content">
<h1>Permission Required</h1>
<p>Kanban TODO needs permission to view and edit your file.</p>
<button id="grant-permission-btn">Grant Access</button>
</div>
<div id="section-screen" class="screen center-content">
<h1>Select Project</h1>
<ul id="section-list" class="section-list"></ul>
<div class="new-project-form">
<input type="text" id="new-project-input" placeholder="New project name...">
<button id="create-project-btn">Create</button>
</div>
<button id="change-file-btn" style="margin-top: 1em">Change File</button>
</div>
<div id="board-screen" class="screen">
<header>
<h2 id="board-title" style="margin:0">Project Name</h2>
<div class="header-controls">
<button id="burger-btn" class="burger-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 8l16 0" />
<path d="M4 16l16 0" />
</svg>
</button>
<div id="burger-menu" class="burger-menu">
<button class="burger-menu-item" id="toggle-completed-menu-item">
<div class="burger-menu-checkbox" id="completed-checkbox"></div>
<span>Show completed</span>
</button>
<button class="burger-menu-item" id="back-menu-item">
<span>Projects</span>
</button>
<button class="burger-menu-item" id="about-menu-item">
<span>About</span>
</button>
</div>
</div>
</header>
<div id="board-container" class="board"></div>
</div>
<div id="move-modal" class="modal">
<div class="modal-content">
<h3>Move to Project</h3>
<div class="modal-section">
<label for="target-project-select">Project:</label>
<select id="target-project-select" class="project-select"></select>
</div>
<div class="modal-section">
<label for="target-column-select">Column:</label>
<select id="target-column-select" class="column-select"></select>
</div>
<div class="modal-buttons">
<button id="move-cancel-btn">Cancel</button>
<button id="move-confirm-btn">Move</button>
</div>
</div>
</div>
<div id="card-detail-modal" class="modal">
<div class="modal-content">
<h3 id="card-detail-title">Card Details</h3>
<div class="card-detail-content" id="card-detail-content"></div>
<div class="modal-buttons">
<button id="card-detail-close-btn">Close</button>
</div>
</div>
</div>
<div id="about-modal" class="modal">
<div class="modal-content">
<h3>About Kanban TODO</h3>
<p>A Kanban board for your Markdown TODO file.</p>
<ul style="text-align: left;">
<li>Drag-and-drop visual editing of plain text Markdown</li>
<li>Runs 100% in your browser - no server, no database</li>
<li>Single HTML file - upload to self-host</li>
<li>CLI tool to launch as a Chrome app: <code>kbtd TODO.md</code></li>
</ul>
<div id="about-instructions"></div>
<p><a href="https://github.com/chr15m/kanban-todo" target="_blank">Source code for self-hosting</a></p>
<p>Copyright &copy; Chris McCormick 2025</p>
<div class="modal-buttons">
<button id="about-close-btn">Close</button>
</div>
</div>
</div>
</div>
<script>
const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);
const DB_NAME = 'kbtd';
const DB_VERSION = 1;
const STORE_NAME = 'fileHandles';
let state = {
fileHandle: null,
filePath: null,
sections: [],
currentSectionIndex: -1,
lastModified: 0,
showCompleted: false
};
let dragState = {
source: null,
currentDropTarget: null
};
let moveModalState = {
cardLineNumber: null,
cardOriginalLine: null,
sourceColIndex: null,
sourceCardIndex: null
};
function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k.startsWith('on')) el[k] = v;
else if (k === 'dataset') Object.assign(el.dataset, v);
else if (k === 'className') el.className = v;
else if (k === 'style' && typeof v === 'object') {
Object.assign(el.style, v);
} else el[k] = v;
}
children.flat().forEach(c => {
if (c != null) el.append(c);
});
return el;
}
function getDataInt(el, key) {
return parseInt(el.dataset[key]);
}
function insertInstallInstructions(containerId) {
const template = $('#install-instructions-template');
const clone = template.content.cloneNode(true);
const currentUrl = window.location.href.split('?')[0];
clone.querySelector('.current-url').textContent = currentUrl;
$(`#${containerId}`).appendChild(clone);
}
function createAddForm(placeholder, onSubmit) {
const input = h('input', { type: 'text', placeholder });
const form = h('div', { className: 'add-card-form' }, input);
const handleSubmit = async () => {
const value = input.value.trim();
if (!value) {
alert(`Please enter ${placeholder.toLowerCase()}`);
return;
}
await onSubmit(value);
};
input.onkeypress = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
const buttons = h('div', { className: 'add-card-form-buttons' },
h('button', { textContent: 'Add', onclick: handleSubmit }),
h('button', {
textContent: 'Cancel',
onclick: () => form.hide()
})
);
form.appendChild(buttons);
form.show = () => {
form.classList.add('active');
input.focus();
};
form.hide = () => {
form.classList.remove('active');
input.value = '';
};
return form;
}
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
async function getFileHandle(path) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(path);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function storeFileHandle(path, handle) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.put(handle, path);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async function removeFileHandle(path) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(path);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async function getAllStoredProjects() {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAllKeys();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function validateFileHandle(handle) {
try {
const perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') {
await handle.getFile();
return 'valid';
}
return 'needs-permission';
} catch (err) {
console.log('File handle validation failed:', err);
if (err.name === 'NotFoundError') {
return 'not-found';
}
return 'error';
}
}
function parseMarkdown(text) {
const lines = text.split('\n');
const sections = [];
let currentSection = null;
let currentColumn = null;
let currentCard = null;
const finishCard = () => {
if (currentCard) {
if (currentColumn) {
currentColumn.cards.push(currentCard);
}
currentCard = null;
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (line.startsWith('# ')) {
finishCard();
currentSection = {
name: line.substring(2).trim(),
headerLineNumber: i,
columns: []
};
sections.push(currentSection);
currentColumn = null;
}
else if (line.startsWith('## ') && currentSection) {
finishCard();
currentColumn = {
name: line.substring(3).trim(),
headerLineNumber: i,
cards: []
};
currentSection.columns.push(currentColumn);
}
else if ((trimmed.startsWith('- [ ]') || trimmed.startsWith('- [x]')) && currentColumn) {
const isIndented = line.startsWith(' ') || line.startsWith('\t');
if (isIndented && currentCard) {
currentCard.nestedLines.push(line);
} else {
finishCard();
const isCompleted = trimmed.startsWith('- [x]');
const textStart = line.indexOf(']') + 2;
const text = line.substring(textStart).trim();
currentCard = {
text: text,
completed: isCompleted,
lineNumber: i,
originalLine: line,
nestedLines: []
};
}
}
else {
if (currentCard) {
const isIndented = line.startsWith(' ') || line.startsWith('\t');
if (isIndented) {
currentCard.nestedLines.push(line);
}
}
}
}
finishCard();
return sections;
}
function reloadFromText(text) {
console.log('Reloading from text');
state.sections = parseMarkdown(text);
console.log('Parsed sections:', state.sections);
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
renderBoard();
} else {
renderSectionPicker();
}
}
async function writeFile(text) {
try {
console.log('Writing file and updating UI');
reloadFromText(text);
const writable = await state.fileHandle.createWritable();
await writable.write(text);
await writable.close();
console.log('File written successfully');
const file = await state.fileHandle.getFile();
state.lastModified = file.lastModified;
} catch (err) {
console.error('Error writing file:', err);
alert('Error saving file.');
}
}
async function modifyFileLines(fn) {
const file = await state.fileHandle.getFile();
const text = await file.text();
const lines = text.split('\n');
fn(lines);
await writeFile(lines.join('\n'));
}
async function updateLineInFile(lineNum, newText) {
console.log('updateLineInFile:', lineNum, newText);
await modifyFileLines(lines => {
lines[lineNum] = newText;
});
}
function findCardByLineNumber(lineNumber) {
const section = state.sections[state.currentSectionIndex];
for (let colIndex = 0; colIndex < section.columns.length; colIndex++) {
const column = section.columns[colIndex];
for (let cardIndex = 0; cardIndex < column.cards.length; cardIndex++) {
const card = column.cards[cardIndex];
if (card.lineNumber === lineNumber) {
return { card, colIndex, cardIndex };
}
}
}
return null;
}
async function moveLineInFile(fromLine, toLine, targetColumnIndex, insertPosition) {
console.log('moveLineInFile: from line', fromLine, 'to line', toLine, 'targetColumnIndex:', targetColumnIndex, 'insertPosition:', insertPosition);
await modifyFileLines(lines => {
const section = state.sections[state.currentSectionIndex];
const targetColumn = section.columns[targetColumnIndex];
const targetColumnIsEmpty = targetColumn.cards.length === 0;
const isInsertingAtBottom = insertPosition === targetColumn.cards.length;
const cardInfo = findCardByLineNumber(fromLine);
if (!cardInfo) {
console.error('Could not find card with line number', fromLine);
return;
}
const nestedLineCount = cardInfo.card.nestedLines.length;
console.log('Card has', nestedLineCount, 'nested lines');
console.log('Before move, source line', fromLine, '=', lines[fromLine]);
console.log('Target column is empty:', targetColumnIsEmpty);
console.log('Inserting at bottom:', isInsertingAtBottom);
const movedLines = lines.splice(fromLine, 1 + nestedLineCount);
let actualInsertLine;
if (targetColumnIsEmpty) {
console.log('Moving to empty column - inserting after header');
actualInsertLine = targetColumn.headerLineNumber + 1;
if (actualInsertLine < lines.length && lines[actualInsertLine].trim() === '') {
lines.splice(actualInsertLine, 0, ...movedLines);
} else {
lines.splice(actualInsertLine, 0, '', ...movedLines);
}
const afterLine = actualInsertLine + (lines[actualInsertLine].trim() === '' ? 1 + movedLines.length : movedLines.length);
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
lines.splice(afterLine, 0, '');
}
} else {
if (insertPosition === 0) {
console.log('Inserting at start of column');
actualInsertLine = targetColumn.headerLineNumber + 1;
if (actualInsertLine < lines.length && lines[actualInsertLine].trim() === '') {
actualInsertLine++;
}
lines.splice(actualInsertLine, 0, ...movedLines);
} else {
console.log('Inserting at position', insertPosition);
const cardBefore = targetColumn.cards[insertPosition - 1];
actualInsertLine = cardBefore.lineNumber + 1;
if (cardBefore.nestedLines.length > 0) {
actualInsertLine += cardBefore.nestedLines.length;
}
if (fromLine < actualInsertLine) {
actualInsertLine -= (1 + nestedLineCount);
}
if (actualInsertLine > 0 && lines[actualInsertLine - 1].trim() === '') {
console.log('Line before target is blank, replacing it');
lines.splice(actualInsertLine - 1, 1, ...movedLines);
} else {
console.log('Inserting at target position');
lines.splice(actualInsertLine, 0, ...movedLines);
}
if (isInsertingAtBottom) {
const movedLineIndex = lines[actualInsertLine - 1] === movedLines[0] ? actualInsertLine - 1 : actualInsertLine;
const afterLine = movedLineIndex + movedLines.length;
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
console.log('Inserting at bottom - ensuring blank line after moved item at line', afterLine);
lines.splice(afterLine, 0, '');
}
}
}
}
console.log('After move, lines around target:');
for (let i = Math.max(0, actualInsertLine - 2); i <= Math.min(lines.length - 1, actualInsertLine + movedLines.length + 2); i++) {
console.log(` line ${i}: "${lines[i]}"`);
}
});
}
async function moveLineBetweenSections(fromLine, targetSectionIndex, targetColumnIndex) {
console.log('moveLineBetweenSections: from line', fromLine, 'to section', targetSectionIndex, 'column', targetColumnIndex);
await modifyFileLines(lines => {
const cardInfo = findCardByLineNumber(fromLine);
if (!cardInfo) {
console.error('Could not find card with line number', fromLine);
return;
}
const nestedLineCount = cardInfo.card.nestedLines.length;
console.log('Card has', nestedLineCount, 'nested lines');
const movedLines = lines.splice(fromLine, 1 + nestedLineCount);
const targetSection = state.sections[targetSectionIndex];
const targetColumn = targetSection.columns[targetColumnIndex];
let insertLine;
if (targetColumn.cards.length === 0) {
insertLine = targetColumn.headerLineNumber + 1;
if (fromLine < insertLine) {
insertLine -= (1 + nestedLineCount);
}
if (insertLine < lines.length && lines[insertLine].trim() === '') {
lines.splice(insertLine, 0, ...movedLines);
} else {
lines.splice(insertLine, 0, '', ...movedLines);
}
const afterLine = insertLine + (lines[insertLine].trim() === '' ? 1 + movedLines.length : movedLines.length);
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
lines.splice(afterLine, 0, '');
}
} else {
const lastCard = targetColumn.cards[targetColumn.cards.length - 1];
insertLine = lastCard.lineNumber + 1;
if (lastCard.nestedLines.length > 0) {
insertLine += lastCard.nestedLines.length;
}
if (fromLine < insertLine) {
insertLine -= (1 + nestedLineCount);
}
lines.splice(insertLine, 0, ...movedLines);
const afterLine = insertLine + movedLines.length;
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
lines.splice(afterLine, 0, '');
}
}
console.log('Moved lines to position', insertLine);
});
}
async function createProject(projectName) {
console.log('Creating project:', projectName);
await modifyFileLines(lines => {
const text = lines.join('\n');
let newText;
if (text.trim() === '') {
newText = `# ${projectName}\n\n## TODO\n\n## In Progress\n\n## Done\n`;
} else {
newText = text + (text.endsWith('\n') ? '' : '\n') + `\n# ${projectName}\n\n## TODO\n\n## In Progress\n\n## Done\n`;
}
lines.length = 0;
lines.push(...newText.split('\n'));
});
const newSectionIndex = state.sections.findIndex(s => s.name === projectName);
if (newSectionIndex !== -1) {
await selectSection(newSectionIndex);
}
}
async function createColumn(columnName) {
console.log('Creating column:', columnName);
await modifyFileLines(lines => {
const section = state.sections[state.currentSectionIndex];
let insertLine;
if (section.columns.length === 0) {
insertLine = section.headerLineNumber + 1;
} else {
const lastColumn = section.columns[section.columns.length - 1];
insertLine = lastColumn.headerLineNumber + 1;
while (insertLine < lines.length) {
const line = lines[insertLine];
if (line.startsWith('# ') || line.startsWith('## ')) {
break;
}
insertLine++;
}
}
console.log('Inserting new column at line', insertLine);
if (insertLine > 0 && lines[insertLine - 1].trim() !== '') {
lines.splice(insertLine, 0, '');
insertLine++;
}
lines.splice(insertLine, 0, `## ${columnName}`);
if (insertLine + 1 >= lines.length || lines[insertLine + 1].trim() !== '') {
lines.splice(insertLine + 1, 0, '');
}
});
}
async function createCard(columnIndex, cardText) {
console.log('Creating card in column', columnIndex, ':', cardText);
await modifyFileLines(lines => {
const section = state.sections[state.currentSectionIndex];
const column = section.columns[columnIndex];
let insertLine;
if (column.cards.length === 0) {
insertLine = column.headerLineNumber + 1;
if (insertLine < lines.length && lines[insertLine].trim() === '') {
insertLine++;
}
lines.splice(insertLine, 0, `- [ ] ${cardText}`);
if (insertLine + 1 >= lines.length || lines[insertLine + 1].trim() !== '') {
lines.splice(insertLine + 1, 0, '');
}
} else {
const lastCard = column.cards[column.cards.length - 1];
insertLine = lastCard.lineNumber + 1;
if (lastCard.nestedLines.length > 0) {
insertLine += lastCard.nestedLines.length;
}
lines.splice(insertLine, 0, `- [ ] ${cardText}`);
}
console.log('Inserted new card at line', insertLine);
});
}
async function loadFile() {
try {
const file = await state.fileHandle.getFile();
state.lastModified = file.lastModified;
const text = await file.text();
state.sections = parseMarkdown(text);
console.log('Parsed sections:', state.sections);
if (state.filePath) {
const key = `kbtd:${state.filePath}`;
const stored = localStorage.getItem(key);
if (stored) {
try {
const uiState = JSON.parse(stored);
if (state.currentSectionIndex === -1) {
const idx = state.sections.findIndex(s => s.name === uiState.currentSection);
if (idx !== -1) state.currentSectionIndex = idx;
}
if (uiState.showCompleted !== undefined) {
state.showCompleted = uiState.showCompleted;
}
} catch (e) { console.error(e); }
}
}
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
renderBoard();
} else {
renderSectionPicker();
}
} catch (err) {
console.error('Error loading file:', err);
throw err;
}
}
async function checkPermission(handle) {
const opts = { mode: 'readwrite' };
if ((await handle.queryPermission(opts)) === 'granted') {
return true;
}
if ((await handle.requestPermission(opts)) === 'granted') {
return true;
}
return false;
}
function showScreen(screenId) {
$$('.screen').forEach(el => el.classList.remove('active'));
$(`#${screenId}`).classList.add('active');
}
function renderSectionPicker() {
const list = $('#section-list');
list.replaceChildren(
...state.sections.map((section, index) =>
h('li', {},
h('button', {
className: 'section-btn',
textContent: section.name,
onclick: () => selectSection(index)
})
)
)
);
$('#new-project-input').value = '';
showScreen('section-screen');
}
function closeAllCardMenus() {
$$('.card-menu').forEach(menu => menu.classList.remove('active'));
}
function closeBurgerMenu() {
$('#burger-menu').classList.remove('active');
}
function updateCompletedCheckbox() {
const checkbox = $('#completed-checkbox');
if (state.showCompleted) {
checkbox.classList.add('checked');
} else {
checkbox.classList.remove('checked');
}
}
function openMoveModal(cardLineNumber, cardOriginalLine, sourceColIndex, sourceCardIndex) {
moveModalState = {
cardLineNumber,
cardOriginalLine,
sourceColIndex,
sourceCardIndex
};
const projectSelect = $('#target-project-select');
projectSelect.replaceChildren(
...state.sections.map((section, index) =>
h('option', {
value: index,
textContent: section.name,
selected: index === state.currentSectionIndex
})
)
);
updateColumnSelect();
$('#move-modal').classList.add('active');
}
function updateColumnSelect() {
const projectSelect = $('#target-project-select');
const selectedProjectIndex = parseInt(projectSelect.value);
const selectedProject = state.sections[selectedProjectIndex];
const columnSelect = $('#target-column-select');
columnSelect.replaceChildren(
...selectedProject.columns.map((column, index) =>
h('option', {
value: index,
textContent: column.name
})
)
);
}
function closeMoveModal() {
$('#move-modal').classList.remove('active');
moveModalState = {
cardLineNumber: null,
cardOriginalLine: null,
sourceColIndex: null,
sourceCardIndex: null
};
}
function openCardDetailModal(card) {
$('#card-detail-title').textContent = card.text;
const fullText = [card.originalLine, ...card.nestedLines].join('\n');
$('#card-detail-content').textContent = fullText;
$('#card-detail-modal').classList.add('active');
}
function closeCardDetailModal() {
$('#card-detail-modal').classList.remove('active');
}
function openAboutModal() {
$('#about-modal').classList.add('active');
}
function closeAboutModal() {
$('#about-modal').classList.remove('active');
}
async function confirmMove() {
const targetSectionIndex = parseInt($('#target-project-select').value);
const targetColumnIndex = parseInt($('#target-column-select').value);
console.log('Moving card from section', state.currentSectionIndex, 'to section', targetSectionIndex, 'column', targetColumnIndex);
if (targetSectionIndex === state.currentSectionIndex) {
await moveLineInFile(
moveModalState.cardLineNumber,
0,
targetColumnIndex,
state.sections[targetSectionIndex].columns[targetColumnIndex].cards.length
);
} else {
await moveLineBetweenSections(
moveModalState.cardLineNumber,
targetSectionIndex,
targetColumnIndex
);
}
closeMoveModal();
}
function renderBoard() {
const section = state.sections[state.currentSectionIndex];
if (!section) return renderSectionPicker();
$('#board-title').textContent = section.name;
updateCompletedCheckbox();
const container = $('#board-container');
const columns = section.columns.map((column, colIndex) => {
const visibleCards = column.cards.filter(card =>
state.showCompleted || !card.completed
);
const cardElements = [];
if (visibleCards.length > 0) {
cardElements.push(
h('div', {
className: 'drop-indicator',
dataset: { colIndex, position: 0 }
})
);
}
column.cards.forEach((card, cardIndex) => {
if (!state.showCompleted && card.completed) return;
const hasNestedContent = card.nestedLines.length > 0;
const cardDiv = h('div', {
className: `card ${card.completed ? 'completed' : ''}`,
draggable: true,
dataset: {
lineNumber: card.lineNumber,
originalLine: card.originalLine,
colIndex,
cardIndex
},
ondragstart: (e) => handleDragStart(e, cardDiv),
ondragend: handleDragEnd,
ondragenter: (e) => handleCardDragEnter(e, cardDiv, colIndex, cardIndex),
onclick: (e) => {
if (hasNestedContent && !e.target.closest('.card-menu-btn')) {
openCardDetailModal(card);
}
}
});
if (hasNestedContent) {
cardDiv.style.cursor = 'pointer';
}
const expandIndicator = hasNestedContent ?
h('span', {
className: 'card-expand-indicator',
textContent: '+'
}) : null;
const cardText = h('span', {
className: 'card-text'
}, card.text, expandIndicator);
const menuBtn = h('button', {
className: 'card-menu-btn',
textContent: '⋮',
onclick: (e) => {
e.stopPropagation();
const menu = cardDiv.querySelector('.card-menu');
const wasActive = menu.classList.contains('active');
closeAllCardMenus();
if (!wasActive) {
menu.classList.add('active');
}
}
});
const checkbox = h('div', {
className: `card-menu-checkbox ${card.completed ? 'checked' : ''}`
});
const completeItem = h('button', {
className: 'card-menu-item',
onclick: async (e) => {
e.stopPropagation();
closeAllCardMenus();
const lineNum = getDataInt(cardDiv, 'lineNumber');
const originalLine = cardDiv.dataset.originalLine;
const newLine = originalLine.includes('- [x]')
? originalLine.replace('- [x]', '- [ ]')
: originalLine.replace('- [ ]', '- [x]');
await updateLineInFile(lineNum, newLine);
}
}, checkbox, h('span', { textContent: 'Complete' }));
const moveItem = h('button', {
className: 'card-menu-item',
textContent: 'Move to project...',
onclick: (e) => {
e.stopPropagation();
closeAllCardMenus();
openMoveModal(
getDataInt(cardDiv, 'lineNumber'),
cardDiv.dataset.originalLine,
colIndex,
cardIndex
);
}
});
const menu = h('div', {
className: 'card-menu'
}, completeItem, moveItem);
cardDiv.append(cardText, menuBtn, menu);
cardElements.push(
cardDiv,
h('div', {
className: 'drop-indicator',
dataset: { colIndex, position: cardIndex + 1 }
})
);
});
if (visibleCards.length === 0) {
cardElements.push(
h('div', {
className: 'drop-indicator',
dataset: { colIndex, position: 0 }
})
);
}
const cardList = h('div', {
className: 'card-list',
dataset: { colIndex },
ondragover: (e) => handleDragOver(e, colIndex, cardList),
ondrop: (e) => handleDrop(e, colIndex),
ondragleave: handleDragLeave
}, ...cardElements);
const addCardForm = createAddForm('Card text...', async (text) => {
await createCard(colIndex, text);
addCardForm.hide();
addCardBtn.style.display = '';
});
const addCardBtn = h('button', {
className: 'add-card-btn',
textContent: '+',
title: 'Add card',
onclick: () => {
addCardBtn.style.display = 'none';
addCardForm.show();
}
});
return h('div', {
className: 'column',
dataset: { colIndex }
},
h('div', { className: 'column-header', textContent: column.name }),
cardList,
h('div', { className: 'column-footer' }, addCardBtn, addCardForm)
);
});
const addColumnForm = createAddForm('Column name...', async (name) => {
const section = state.sections[state.currentSectionIndex];
if (section.columns.some(c => c.name === name)) {
alert('A column with this name already exists');
return;
}
await createColumn(name);
addColumnForm.hide();
});
addColumnForm.className = 'add-column-form';
const addColumnDiv = h('div', { className: 'add-column' },
h('button', {
className: 'add-column-btn',
textContent: '+',
title: 'Add column',
onclick: () => addColumnForm.classList.toggle('active')
}),
addColumnForm
);
container.replaceChildren(...columns, addColumnDiv);
showScreen('board-screen');
}
async function selectSection(index) {
state.currentSectionIndex = index;
if (state.filePath) {
const key = `kbtd:${state.filePath}`;
const uiState = {
currentSection: state.sections[index].name,
showCompleted: state.showCompleted
};
localStorage.setItem(key, JSON.stringify(uiState));
}
renderBoard();
}
function toggleShowCompleted() {
state.showCompleted = !state.showCompleted;
if (state.filePath) {
const key = `kbtd:${state.filePath}`;
const stored = localStorage.getItem(key);
let uiState = {};
if (stored) {
try {
uiState = JSON.parse(stored);
} catch (e) { console.error(e); }
}
uiState.showCompleted = state.showCompleted;
localStorage.setItem(key, JSON.stringify(uiState));
}
renderBoard();
}
function clearDropIndicators() {
$$('.drop-indicator').forEach(el => el.classList.remove('active'));
}
function setActiveDropIndicator(colIndex, position) {
console.log('Setting active drop indicator: col', colIndex, 'position', position);
dragState.currentDropTarget = { colIndex, position };
clearDropIndicators();
const indicator = $(`.drop-indicator[data-col-index="${colIndex}"][data-position="${position}"]`);
if (indicator) indicator.classList.add('active');
}
function handleDragStart(e, cardDiv) {
closeAllCardMenus();
dragState.source = {
lineNumber: getDataInt(cardDiv, 'lineNumber'),
colIndex: getDataInt(cardDiv, 'colIndex'),
cardIndex: getDataInt(cardDiv, 'cardIndex')
};
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify(dragState.source));
console.log('Drag started:', dragState.source);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
clearDropIndicators();
dragState.source = null;
dragState.currentDropTarget = null;
}
function handleCardDragEnter(e, cardDiv, colIndex, cardIndex) {
if (!dragState.source) return;
e.preventDefault();
e.stopPropagation();
const rect = cardDiv.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const position = e.clientY < midpoint ? cardIndex : cardIndex + 1;
setActiveDropIndicator(colIndex, position);
}
function handleDragOver(e, colIndex, cardList) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragState.source) return;
const cards = Array.from(cardList.querySelectorAll('.card'));
if (cards.length === 0) {
console.log('Drag over empty column', colIndex);
setActiveDropIndicator(colIndex, 0);
return;
}
let closestCard = null;
let closestDistance = Infinity;
let insertBefore = true;
cards.forEach(card => {
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const distance = Math.abs(e.clientY - midpoint);
if (distance < closestDistance) {
closestDistance = distance;
closestCard = card;
insertBefore = e.clientY < midpoint;
}
});
if (closestCard) {
const cardIndex = getDataInt(closestCard, 'cardIndex');
const position = insertBefore ? cardIndex : cardIndex + 1;
if (!dragState.currentDropTarget ||
dragState.currentDropTarget.colIndex !== colIndex ||
dragState.currentDropTarget.position !== position) {
setActiveDropIndicator(colIndex, position);
}
}
}
function handleDragLeave(e) {
if (e.target.classList.contains('card-list')) {
const relatedTarget = e.relatedTarget;
if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) {
console.log('Drag leave card-list');
}
}
}
async function handleDrop(e, targetColIndex) {
e.stopPropagation();
e.preventDefault();
clearDropIndicators();
if (!dragState.source || !dragState.currentDropTarget) {
console.log('Drop ignored: no source or target');
return;
}
const sourceColIndex = dragState.source.colIndex;
const sourceLineNumber = dragState.source.lineNumber;
const targetPosition = dragState.currentDropTarget.position;
console.log('Drop: sourceCol', sourceColIndex, 'targetCol', targetColIndex, 'position', targetPosition);
if (sourceColIndex === targetColIndex) {
const sourceCardIndex = dragState.source.cardIndex;
if (targetPosition === sourceCardIndex || targetPosition === sourceCardIndex + 1) {
console.log('Same position, ignoring drop');
dragState.source = null;
dragState.currentDropTarget = null;
return;
}
}
const section = state.sections[state.currentSectionIndex];
const targetColumn = section.columns[targetColIndex];
console.log('Target column:', targetColumn.name, 'position:', targetPosition);
await moveLineInFile(sourceLineNumber, 0, targetColIndex, targetPosition);
dragState.source = null;
dragState.currentDropTarget = null;
return false;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.card-menu') && !e.target.closest('.card-menu-btn')) {
closeAllCardMenus();
}
if (!e.target.closest('.burger-menu') && !e.target.closest('.burger-btn')) {
closeBurgerMenu();
}
if (e.target.id === 'about-modal') {
closeAboutModal();
}
if (e.target.id === 'card-detail-modal') {
closeCardDetailModal();
}
});
async function openRecentProject(projectName) {
console.log('Opening recent project:', projectName);
state.filePath = projectName;
const newUrl = `${window.location.pathname}?file=${encodeURIComponent(projectName)}`;
window.history.pushState({}, '', newUrl);
try {
const handle = await getFileHandle(projectName);
if (!handle) {
$('#landing-error').textContent =
`Could not find stored file handle for: ${projectName}`;
$('#landing-error').style.display = 'block';
return;
}
state.fileHandle = handle;
const perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') {
await loadFile();
startPolling();
} else {
showScreen('permission-screen');
}
} catch (err) {
console.error('Error loading project:', err);
$('#landing-error').textContent =
`This file is no longer accessible. Please select it again.`;
$('#landing-error').style.display = 'block';
}
}
async function removeRecentProject(projectName) {
console.log('Removing recent project:', projectName);
await removeFileHandle(projectName);
await renderRecentProjects();
}
async function renderRecentProjects() {
try {
const projects = await getAllStoredProjects();
console.log('Recent projects:', projects);
if (projects.length > 0) {
const projectsWithStatus = await Promise.all(
projects.map(async (projectName) => {
const handle = await getFileHandle(projectName);
const status = handle ? await validateFileHandle(handle) : 'not-found';
return { projectName, status };
})
);
const list = $('#recent-projects-list');
list.replaceChildren(
...projectsWithStatus.map(({ projectName, status }) => {
const isClickable = status === 'valid' || status === 'needs-permission';
const showWarning = status === 'not-found' || status === 'error';
return h('li', { className: 'recent-project-item' },
h('button', {
className: `recent-project-btn ${isClickable ? '' : 'unavailable'}`,
onclick: isClickable ? () => openRecentProject(projectName) : () => {
alert('This file is no longer accessible. Please select it again or remove it from the list.');
}
},
showWarning ? h('span', {
className: 'recent-project-warning',
textContent: '⚠ '
}) : null,
h('span', { textContent: projectName })
),
h('button', {
className: 'remove-project-btn',
textContent: '×',
title: 'Remove from list',
onclick: () => removeRecentProject(projectName)
})
);
})
);
$('#recent-projects').style.display = 'block';
} else {
$('#recent-projects').style.display = 'none';
}
} catch (err) {
console.error('Error loading recent projects:', err);
$('#recent-projects').style.display = 'none';
}
}
async function init() {
insertInstallInstructions('landing-instructions');
insertInstallInstructions('about-instructions');
const params = new URLSearchParams(window.location.search);
state.filePath = params.get('file');
if (!state.filePath) {
await renderRecentProjects();
showScreen('landing-screen');
return;
}
try {
const handle = await getFileHandle(state.filePath);
if (handle) {
state.fileHandle = handle;
const perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') {
await loadFile();
startPolling();
} else {
showScreen('permission-screen');
}
} else {
await renderRecentProjects();
showScreen('landing-screen');
$('#landing-error').textContent =
`Please select the file: ${state.filePath}`;
$('#landing-error').style.display = 'block';
}
} catch (err) {
console.error(err);
await renderRecentProjects();
showScreen('landing-screen');
$('#landing-error').textContent =
`Error loading file. Please select it again.`;
$('#landing-error').style.display = 'block';
}
}
function startPolling() {
setInterval(async () => {
if (!state.fileHandle) return;
try {
const file = await state.fileHandle.getFile();
if (file.lastModified > state.lastModified) {
console.log('File changed externally, reloading...');
await loadFile();
}
} catch (e) {
console.error('Polling error:', e);
}
}, 250);
}
$('#open-file-btn').onclick = async () => {
const projectNameInput = $('#project-name-input');
const projectName = projectNameInput.value.trim();
if (!projectName) {
alert('Please enter a project name');
return;
}
try {
const [handle] = await window.showOpenFilePicker({
id: 'kbtd-todo-file',
mode: 'readwrite',
types: [{
description: 'Markdown files',
accept: { 'text/markdown': ['.md'] }
}]
});
state.fileHandle = handle;
state.filePath = projectName;
await storeFileHandle(projectName, handle);
const newUrl = `${window.location.pathname}?file=${encodeURIComponent(projectName)}`;
window.history.pushState({}, '', newUrl);
await loadFile();
startPolling();
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
alert('Error opening file');
}
}
};
$('#project-name-input').onkeypress = (e) => {
if (e.key === 'Enter') {
$('#open-file-btn').click();
}
};
$('#grant-permission-btn').onclick = async () => {
if (state.fileHandle) {
if (await checkPermission(state.fileHandle)) {
await loadFile();
startPolling();
} else {
showScreen('landing-screen');
}
} else {
showScreen('landing-screen');
}
};
$('#burger-btn').onclick = () => {
$('#burger-menu').classList.toggle('active');
};
$('#toggle-completed-menu-item').onclick = () => {
toggleShowCompleted();
};
$('#back-menu-item').onclick = () => {
closeBurgerMenu();
state.currentSectionIndex = -1;
renderSectionPicker();
};
$('#about-menu-item').onclick = () => {
closeBurgerMenu();
openAboutModal();
};
$('#about-close-btn').onclick = () => {
closeAboutModal();
};
$('#card-detail-close-btn').onclick = () => {
closeCardDetailModal();
};
$('#change-file-btn').onclick = () => {
showScreen('landing-screen');
$('#project-name-input').value = state.filePath || '';
};
$('#create-project-btn').onclick = async () => {
const input = $('#new-project-input');
const projectName = input.value.trim();
if (!projectName) {
alert('Please enter a project name');
return;
}
if (state.sections.some(s => s.name === projectName)) {
alert('A project with this name already exists');
return;
}
await createProject(projectName);
};
$('#new-project-input').onkeypress = (e) => {
if (e.key === 'Enter') {
$('#create-project-btn').click();
}
};
$('#target-project-select').onchange = () => {
updateColumnSelect();
};
$('#move-cancel-btn').onclick = () => {
closeMoveModal();
};
$('#move-confirm-btn').onclick = async () => {
await confirmMove();
};
init();
</script>
</body>
</html>