kanban-todo.txt/index.html
2026-01-07 09:59:31 +08:00

2629 lines
94 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">
<meta name="description" content="A Kanban board for your Markdown TODO file">
<meta name="theme-color" content="#112">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="#" id="manifest-placeholder">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='192' height='192' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='4' fill='%23112'/%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M4 4l6 0' stroke='%23ffe' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3Cpath d='M14 4l6 0' stroke='%23ffe' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3Cpath d='M4 10a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2l0 -8' stroke='%23ffe' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3Cpath d='M14 10a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2l0 -2' stroke='%23ffe' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E" sizes="any" type="image/svg+xml">
<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;
}
textarea {
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: monospace;
resize: vertical;
min-height: 150px;
}
input::placeholder,
textarea::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: pointer;
user-select: none;
word-wrap: break-word;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.card:hover {
border-color: var(--fg);
}
.card.completed .card-header {
opacity: 0.6;
}
.card.dragging {
opacity: 0.5;
border: .1rem dashed var(--fg);
cursor: grabbing;
}
.card-header {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.card-text {
flex-grow: 1;
}
.card.completed .card-text {
text-decoration: line-through;
}
.card-footer {
display: flex;
align-items: center;
justify-content: end;
font-size: 0.85em;
color: var(--shade);
}
.card-footer-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25em;
}
.card-expand-indicator {
color: var(--shade);
font-size: 1.2em;
line-height: 1;
}
.card-progress-bar {
height: 6px;
width: 6ch;
background: #445;
border-radius: 2px;
overflow: hidden;
}
.card-progress-fill {
height: 100%;
background: #4a4;
transition: width 0.2s ease;
}
.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;
text-decoration: none;
}
.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;
opacity: 1;
}
.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);
}
.warning {
background-color: #885500;
color: #ffa500;
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="intro-content-template">
<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>
<li>Great for indie-scale solo projects and small teams</li>
</ul>
</template>
<template id="footer-content-template">
<p><a href="https://github.com/chr15m/kanban-todo/blob/main/README.md" target="_blank">See README.md for more info.</a></p>
</template>
<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>
<template id="project-selection-template">
<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>
</template>
<template id="source-copyright-template">
<p><a href="https://github.com/chr15m/kanban-todo" target="_blank">Source code for self-hosting</a> - upload index.html to your server.</p>
<p>Copyright &copy; Chris McCormick 2025</p>
</template>
<div id="app">
<div id="landing-screen" class="screen center-content">
<div id="landing-intro"></div>
<div id="landing-footer"></div>
<div id="api-warning" class="warning" style="display:none; max-width: 600px;">
⚠ Your browser does not support the FileSystem Access API. Chromium-based browsers (Chrome, Edge, etc.) are known to work.
</div>
<div id="landing-project-selection"></div>
<div id="landing-instructions"></div>
<div id="landing-source-copyright"></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">
<div class="modal-section">
<label for="card-detail-title-input">Title:</label>
<input type="text" id="card-detail-title-input">
</div>
<div class="modal-section">
<label for="card-detail-content-textarea">Details:</label>
<textarea id="card-detail-content-textarea" placeholder="Add details..."></textarea>
</div>
<div class="modal-buttons" id="card-detail-buttons">
<button id="card-detail-close-btn">Close</button>
</div>
</div>
</div>
<div id="about-modal" class="modal">
<div class="modal-content">
<div id="about-intro"></div>
<div id="about-footer"></div>
<div id="about-api-warning" class="warning" style="display:none;">
⚠ Your browser does not support the FileSystem Access API. This app requires a Chromium-based browser (Chrome, Edge, etc.) to work.
</div>
<div id="about-instructions"></div>
<div id="about-source-copyright"></div>
<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
};
let moveModalState = {
cardLineNumber: null,
cardOriginalLine: null,
sourceColIndex: null,
sourceCardIndex: null
};
let cardDetailModalState = {
card: null,
originalTitle: '',
originalContent: '',
baseIndent: ''
};
function checkFileSystemAccessAPI() {
return 'showOpenFilePicker' in window;
}
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 insertIntroContent(containerId) {
const template = $('#intro-content-template');
const clone = template.content.cloneNode(true);
$(`#${containerId}`).appendChild(clone);
}
function insertFooterContent(containerId) {
const template = $('#footer-content-template');
const clone = template.content.cloneNode(true);
$(`#${containerId}`).appendChild(clone);
}
function insertProjectSelection(containerId) {
const template = $('#project-selection-template');
const clone = template.content.cloneNode(true);
$(`#${containerId}`).appendChild(clone);
}
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 insertSourceCopyright(containerId) {
const template = $('#source-copyright-template');
const clone = template.content.cloneNode(true);
$(`#${containerId}`).appendChild(clone);
}
function createAddForm(placeholder, onSubmit, onCancel) {
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();
if (onCancel) onCancel();
}
})
);
form.appendChild(buttons);
form.show = () => {
form.classList.add('active');
input.focus();
};
form.hide = () => {
form.classList.remove('active');
input.value = '';
};
return form;
}
function createModal(id, onOpen, onClose) {
const el = $(`#${id}`);
return {
open: (data) => {
if (onOpen) onOpen(data);
el.classList.add('active');
},
close: () => {
if (onClose) onClose();
el.classList.remove('active');
}
};
}
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 dbOperation(mode, fn) {
const db = await dbPromise;
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = fn(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getFileHandle(path) {
return dbOperation('readonly', store => store.get(path));
}
async function storeFileHandle(path, handle) {
return dbOperation('readwrite', store => store.put(handle, path));
}
async function removeFileHandle(path) {
return dbOperation('readwrite', store => store.delete(path));
}
async function getAllStoredProjects() {
return dbOperation('readonly', store => store.getAllKeys());
}
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 detectIndent(lines, defaultIndent = ' ') {
if (!lines || lines.length === 0) return defaultIndent;
const lineArray = typeof lines === 'string' ? lines.split('\n') : lines;
let minIndent = null;
for (const line of lineArray) {
if (line.trim() === '') continue;
const match = line.match(/^(\s+)/);
if (match) {
const indent = match[1];
if (minIndent === null || indent.length < minIndent.length) {
minIndent = indent;
}
}
}
return minIndent || defaultIndent;
}
function stripBaseIndent(nestedLines, baseIndent) {
if (!baseIndent) return nestedLines.join('\n');
return nestedLines.map(line => {
if (line.startsWith(baseIndent)) {
return line.substring(baseIndent.length);
}
return line;
}).join('\n');
}
function applyBaseIndent(text, baseIndent) {
if (!text.trim()) return [];
return text.split('\n').map(line => {
if (line.trim() === '') return '';
return baseIndent + line;
});
}
function countSubtasks(nestedLines) {
let total = 0;
let completed = 0;
for (const line of nestedLines) {
const trimmed = line.trim();
if (trimmed.startsWith('- [ ]')) {
total++;
} else if (trimmed.startsWith('- [x]')) {
total++;
completed++;
}
}
return { total, completed };
}
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 modifyCard(lineNumber, fn) {
await modifyFileLines(lines => {
const cardInfo = findCardByLineNumber(lineNumber);
if (!cardInfo) {
console.error('Could not find card with line number', lineNumber);
return;
}
fn(lines, cardInfo);
});
}
async function updateCardInFile(lineNumber, newTitle, newNestedContent, baseIndent) {
console.log('updateCardInFile:', lineNumber, newTitle, newNestedContent, baseIndent);
await modifyCard(lineNumber, (lines, cardInfo) => {
const card = cardInfo.card;
const oldNestedLineCount = card.nestedLines.length;
const checkbox = card.completed ? '[x]' : '[ ]';
const newCardLine = `- ${checkbox} ${newTitle}`;
const newNestedLines = applyBaseIndent(newNestedContent, baseIndent);
lines.splice(lineNumber, 1 + oldNestedLineCount, newCardLine, ...newNestedLines);
console.log('Updated card at line', lineNumber);
});
}
async function deleteCard(lineNumber) {
console.log('deleteCard:', lineNumber);
await modifyCard(lineNumber, (lines, cardInfo) => {
const card = cardInfo.card;
const nestedLineCount = card.nestedLines.length;
lines.splice(lineNumber, 1 + nestedLineCount);
console.log('Deleted card at line', lineNumber, 'with', nestedLineCount, 'nested lines');
});
}
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');
}
}
const moveModal = createModal('move-modal', (data) => {
moveModalState = data;
const projectSelect = $('#target-project-select');
projectSelect.replaceChildren(
...state.sections.map((section, index) =>
h('option', {
value: index,
textContent: section.name,
selected: index === state.currentSectionIndex
})
)
);
updateColumnSelect();
}, () => {
moveModalState = {
cardLineNumber: null,
cardOriginalLine: null,
sourceColIndex: null,
sourceCardIndex: null
};
});
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
})
)
);
}
const cardDetailModal = createModal('card-detail-modal', async (card) => {
const file = await state.fileHandle.getFile();
const text = await file.text();
const defaultIndent = detectIndent(text);
const baseIndent = card.nestedLines.length > 0
? detectIndent(card.nestedLines, '')
: defaultIndent;
const strippedContent = stripBaseIndent(card.nestedLines, baseIndent);
cardDetailModalState = {
card: card,
originalTitle: card.text,
originalContent: strippedContent,
baseIndent: baseIndent
};
$('#card-detail-title-input').value = card.text;
$('#card-detail-content-textarea').value = strippedContent;
updateCardDetailButtons();
}, () => {
cardDetailModalState = {
card: null,
originalTitle: '',
originalContent: '',
baseIndent: ''
};
});
function updateCardDetailButtons() {
const titleInput = $('#card-detail-title-input');
const contentTextarea = $('#card-detail-content-textarea');
const buttonsContainer = $('#card-detail-buttons');
const titleChanged = titleInput.value !== cardDetailModalState.originalTitle;
const contentChanged = contentTextarea.value !== cardDetailModalState.originalContent;
const isModified = titleChanged || contentChanged;
if (isModified) {
buttonsContainer.replaceChildren(
h('button', {
id: 'card-detail-cancel-btn',
textContent: 'Cancel',
onclick: () => cardDetailModal.close()
}),
h('button', {
id: 'card-detail-save-btn',
textContent: 'Save',
onclick: saveCardDetail
})
);
} else {
buttonsContainer.replaceChildren(
h('button', {
id: 'card-detail-close-btn',
textContent: 'Close',
onclick: () => cardDetailModal.close()
})
);
}
}
async function saveCardDetail() {
const titleInput = $('#card-detail-title-input');
const contentTextarea = $('#card-detail-content-textarea');
const newTitle = titleInput.value.trim();
const newContent = contentTextarea.value;
if (!newTitle) {
alert('Title cannot be empty');
return;
}
await updateCardInFile(
cardDetailModalState.card.lineNumber,
newTitle,
newContent,
cardDetailModalState.baseIndent
);
cardDetailModal.close();
}
const aboutModal = createModal('about-modal');
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
);
}
moveModal.close();
}
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 subtasks = countSubtasks(card.nestedLines);
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 (!e.target.closest('.card-menu-btn')) {
cardDetailModal.open(card);
}
}
});
const cardHeader = h('div', { className: 'card-header' });
const cardText = h('span', {
className: 'card-text',
textContent: card.text
});
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');
}
}
});
cardHeader.append(cardText, menuBtn);
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: 'Completed' }));
const moveItem = h('button', {
className: 'card-menu-item',
textContent: 'Move to project...',
onclick: (e) => {
e.stopPropagation();
closeAllCardMenus();
moveModal.open({
cardLineNumber: getDataInt(cardDiv, 'lineNumber'),
cardOriginalLine: cardDiv.dataset.originalLine,
sourceColIndex: colIndex,
sourceCardIndex: cardIndex
});
}
});
const deleteItem = h('button', {
className: 'card-menu-item',
textContent: 'Delete',
onclick: async (e) => {
e.stopPropagation();
closeAllCardMenus();
if (confirm('Are you sure you want to delete this card?')) {
const lineNum = getDataInt(cardDiv, 'lineNumber');
await deleteCard(lineNum);
}
}
});
const menu = h('div', {
className: 'card-menu'
}, completeItem, moveItem, deleteItem);
cardDiv.append(cardHeader, menu);
if (hasNestedContent) {
const cardFooter = h('div', { className: 'card-footer' });
const cardFooterContent = h('div', { className: 'card-footer-content' });
if (subtasks.total > 0) {
const percentage = subtasks.total > 0 ? (subtasks.completed / subtasks.total) * 100 : 0;
const expandIndicator = h('span', {
className: 'card-expand-indicator',
textContent: `${subtasks.completed} / ${subtasks.total}`
});
const progressBar = h('div', { className: 'card-progress-bar' },
h('div', {
className: 'card-progress-fill',
style: { width: `${percentage}%` }
})
);
cardFooterContent.append(expandIndicator, progressBar);
} else {
const expandIndicator = h('span', {
className: 'card-expand-indicator',
textContent: '+'
});
cardFooterContent.append(expandIndicator);
}
cardFooter.append(cardFooterContent);
cardDiv.append(cardFooter);
}
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 addCardBtn = h('button', {
className: 'add-card-btn',
textContent: '+',
title: 'Add card',
onclick: () => {
addCardBtn.style.display = 'none';
addCardForm.show();
}
});
const addCardForm = createAddForm('Card text...', async (text) => {
await createCard(colIndex, text);
addCardForm.hide();
addCardBtn.style.display = '';
}, () => {
addCardBtn.style.display = '';
});
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 setActiveDropIndicator(colIndex, position) {
console.log('Setting active drop indicator: col', colIndex, 'position', position);
$$('.drop-indicator').forEach(el => el.classList.remove('active'));
const indicator = $(`.drop-indicator[data-col-index="${colIndex}"][data-position="${position}"]`);
if (indicator) indicator.classList.add('active');
}
function getActiveDropIndicator() {
const active = $('.drop-indicator.active');
if (!active) return null;
return {
colIndex: getDataInt(active, 'colIndex'),
position: getDataInt(active, 'position')
};
}
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');
$$('.drop-indicator').forEach(el => el.classList.remove('active'));
dragState.source = 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;
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();
const dropTarget = getActiveDropIndicator();
$$('.drop-indicator').forEach(el => el.classList.remove('active'));
if (!dragState.source || !dropTarget) {
console.log('Drop ignored: no source or target');
return;
}
const sourceColIndex = dragState.source.colIndex;
const sourceLineNumber = dragState.source.lineNumber;
const targetPosition = dropTarget.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;
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;
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') {
aboutModal.close();
}
if (e.target.id === 'card-detail-modal') {
cardDetailModal.close();
}
});
$('#card-detail-title-input').addEventListener('input', updateCardDetailButtons);
$('#card-detail-content-textarea').addEventListener('input', updateCardDetailButtons);
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() {
insertIntroContent('landing-intro');
insertIntroContent('about-intro');
insertFooterContent('landing-footer');
insertFooterContent('about-footer');
insertProjectSelection('landing-project-selection');
insertInstallInstructions('landing-instructions');
insertInstallInstructions('about-instructions');
insertSourceCopyright('landing-source-copyright');
insertSourceCopyright('about-source-copyright');
setupEventHandlers();
if (!checkFileSystemAccessAPI()) {
$('#api-warning').style.display = 'block';
$('#about-api-warning').style.display = 'block';
$('#open-file-btn').disabled = true;
}
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);
}
function setupEventHandlers() {
$('#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();
aboutModal.open();
};
$('#about-close-btn').onclick = () => {
aboutModal.close();
};
$('#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 = () => {
moveModal.close();
};
$('#move-confirm-btn').onclick = async () => {
await confirmMove();
};
}
// PWA setup
function setupPWA() {
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 24 24"><rect width="24" height="24" rx="4" fill="#112"/><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4l6 0" stroke="#ffe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M14 4l6 0" stroke="#ffe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M4 10a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2l0 -8" stroke="#ffe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M14 10a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2l0 -2" stroke="#ffe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const manifest = {
name: 'Kanban TODO',
short_name: 'Kanban TODO',
display: 'standalone',
theme_color: '#112',
background_color: '#112',
start_url: window.location.pathname,
icons: [{
src: 'data:image/svg+xml,' + encodeURIComponent(iconSvg),
sizes: 'any',
type: 'image/svg+xml'
}]
};
const manifestBlob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
const manifestURL = URL.createObjectURL(manifestBlob);
$('#manifest-placeholder').href = manifestURL;
if ('serviceWorker' in navigator) {
const swCode = `
self.addEventListener('install', (e) => {
console.log('Service Worker installed');
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
console.log('Service Worker activated');
return self.clients.claim();
});
self.addEventListener('fetch', (e) => {
e.respondWith(fetch(e.request));
});
`;
const swBlob = new Blob([swCode], { type: 'application/javascript' });
const swURL = URL.createObjectURL(swBlob);
navigator.serviceWorker.register(swURL).then(() => {
console.log('Service Worker registered');
}).catch((err) => {
console.error('Service Worker registration failed:', err);
});
}
}
setupPWA();
init();
</script>
</body>
</html>