2025-12-30 17:14:27 +08:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2025-12-30 22:30:32 +08:00
|
|
|
<title>Kanban TODO</title>
|
2025-12-30 17:14:27 +08:00
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--fg: #ffe;
|
|
|
|
|
--bg: #112;
|
|
|
|
|
--shade: #889;
|
|
|
|
|
--radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
html {
|
|
|
|
|
font-size: 1.25rem;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*, ::after, ::before {
|
|
|
|
|
box-sizing: inherit;
|
2025-12-30 18:23:15 +08:00
|
|
|
text-decoration-thickness: .1rem;
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--fg);
|
2025-12-30 18:23:15 +08:00
|
|
|
font-family: Helvetica, Arial, sans-serif;
|
2025-12-30 17:14:27 +08:00
|
|
|
padding: 1em;
|
|
|
|
|
height: 100vh;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 18:23:15 +08:00
|
|
|
a {
|
|
|
|
|
color: var(--fg);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
button {
|
|
|
|
|
display: inline-block;
|
2025-12-30 18:23:15 +08:00
|
|
|
vertical-align: middle;
|
|
|
|
|
padding: .4rem .8rem;
|
2025-12-30 17:14:27 +08:00
|
|
|
font-size: 1rem;
|
2025-12-30 18:23:15 +08:00
|
|
|
font-weight: normal;
|
2025-12-30 17:14:27 +08:00
|
|
|
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);
|
2025-12-30 18:23:15 +08:00
|
|
|
border: .1rem solid var(--shade);
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:17:55 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 18:23:15 +08:00
|
|
|
h1 { font-size: 2em; }
|
2025-12-30 22:30:32 +08:00
|
|
|
h2 { font-size: 1.5em; margin-bottom: 0.5em; }
|
2025-12-30 18:23:15 +08:00
|
|
|
h3 { font-size: 1.17em; }
|
2025-12-30 17:14:27 +08:00
|
|
|
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 {
|
|
|
|
|
justify-content: center;
|
|
|
|
|
align-items: center;
|
|
|
|
|
text-align: center;
|
|
|
|
|
gap: 1.5em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding-bottom: 1em;
|
2025-12-30 18:23:15 +08:00
|
|
|
border-bottom: .1rem solid var(--shade);
|
2025-12-30 17:14:27 +08:00
|
|
|
margin-bottom: 1em;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 18:28:31 +08:00
|
|
|
.header-controls {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.5em;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
.board {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 1em;
|
|
|
|
|
overflow-x: auto;
|
2025-12-30 21:41:44 +08:00
|
|
|
flex-grow: 1;
|
|
|
|
|
align-items: stretch;
|
2025-12-30 17:14:27 +08:00
|
|
|
padding-bottom: 1em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.column {
|
2025-12-30 18:23:15 +08:00
|
|
|
background: transparent;
|
2025-12-30 17:14:27 +08:00
|
|
|
border-radius: var(--radius);
|
2025-12-30 18:23:15 +08:00
|
|
|
min-width: 200px;
|
|
|
|
|
max-width: 450px;
|
|
|
|
|
flex: 1 1 0;
|
2025-12-30 17:14:27 +08:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
2025-12-30 21:41:44 +08:00
|
|
|
height: 100%;
|
2025-12-30 18:23:15 +08:00
|
|
|
border: .1rem solid var(--shade);
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.column-header {
|
|
|
|
|
padding: 0.75em;
|
|
|
|
|
font-weight: bold;
|
2025-12-30 18:23:15 +08:00
|
|
|
border-bottom: .1rem solid var(--shade);
|
2025-12-30 21:41:44 +08:00
|
|
|
flex-shrink: 0;
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card-list {
|
|
|
|
|
padding: 0.5em;
|
|
|
|
|
overflow-y: auto;
|
2025-12-30 21:17:55 +08:00
|
|
|
overflow-x: hidden;
|
2025-12-30 17:14:27 +08:00
|
|
|
flex-grow: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.5em;
|
2025-12-30 21:41:44 +08:00
|
|
|
min-height: 0;
|
2025-12-30 20:32:16 +08:00
|
|
|
position: relative;
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card {
|
2025-12-30 18:23:15 +08:00
|
|
|
background: transparent;
|
2025-12-30 17:14:27 +08:00
|
|
|
padding: 0.75em;
|
|
|
|
|
border-radius: var(--radius);
|
2025-12-30 18:23:15 +08:00
|
|
|
border: .1rem solid var(--shade);
|
2025-12-30 17:14:27 +08:00
|
|
|
cursor: grab;
|
|
|
|
|
user-select: none;
|
|
|
|
|
word-wrap: break-word;
|
2025-12-30 20:19:22 +08:00
|
|
|
position: relative;
|
2025-12-30 21:41:44 +08:00
|
|
|
flex-shrink: 0;
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card:hover {
|
|
|
|
|
border-color: var(--fg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card:active {
|
|
|
|
|
cursor: grabbing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card.completed {
|
|
|
|
|
text-decoration: line-through;
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card.dragging {
|
|
|
|
|
opacity: 0.5;
|
2025-12-30 18:23:15 +08:00
|
|
|
border: .1rem dashed var(--fg);
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 20:19:22 +08:00
|
|
|
.card-checkbox {
|
|
|
|
|
float: right;
|
|
|
|
|
width: 1em;
|
|
|
|
|
height: 1em;
|
|
|
|
|
border: .1rem solid var(--fg);
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
background: transparent;
|
|
|
|
|
margin-left: 0.5em;
|
2025-12-30 20:32:16 +08:00
|
|
|
margin-bottom: 0.5em;
|
2025-12-30 20:19:22 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card-checkbox.checked {
|
|
|
|
|
background: var(--fg);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
.drop-indicator {
|
|
|
|
|
height: 3px;
|
|
|
|
|
background: var(--fg);
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transition: opacity 0.1s;
|
|
|
|
|
pointer-events: none;
|
2025-12-30 21:41:44 +08:00
|
|
|
flex-shrink: 0;
|
2025-12-30 20:32:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.drop-indicator.active {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:24:48 +08:00
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:36:39 +08:00
|
|
|
.column-footer {
|
|
|
|
|
padding: 0.5em;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.5em;
|
|
|
|
|
align-items: flex-end;
|
2025-12-30 21:41:44 +08:00
|
|
|
flex-shrink: 0;
|
2025-12-30 21:36:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
.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;
|
2025-12-30 18:23:15 +08:00
|
|
|
background: transparent;
|
2025-12-30 17:14:27 +08:00
|
|
|
color: var(--fg);
|
2025-12-30 18:23:15 +08:00
|
|
|
border: .1rem solid var(--shade);
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-btn:hover {
|
|
|
|
|
border-color: var(--fg);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:17:55 +08:00
|
|
|
.new-project-form {
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: 600px;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.5em;
|
|
|
|
|
margin-top: 1em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.new-project-form input {
|
|
|
|
|
flex-grow: 1;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
.error {
|
2025-12-30 18:23:15 +08:00
|
|
|
background-color: #782828;
|
|
|
|
|
padding: 0.5em 1em;
|
2025-12-30 17:14:27 +08:00
|
|
|
border-radius: var(--radius);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
::-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);
|
|
|
|
|
}
|
2025-12-30 18:23:15 +08:00
|
|
|
|
|
|
|
|
hr {
|
|
|
|
|
border: .05rem solid var(--fg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
}
|
2025-12-30 22:30:32 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-01-01 14:27:47 +08:00
|
|
|
|
|
|
|
|
code {
|
|
|
|
|
background: transparent;
|
|
|
|
|
padding: 0.2em 0.4em;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
}
|
2025-12-30 17:14:27 +08:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-01-01 14:35:19 +08:00
|
|
|
<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>
|
2026-01-01 14:27:47 +08:00
|
|
|
chmod 755 kbtd
|
|
|
|
|
|
|
|
|
|
# If self-hosting, set KBTD_URL to your URL:
|
2026-01-01 14:42:02 +08:00
|
|
|
# export KBTD_URL="<span class="current-url">https://mccormick.cx/apps/kanban-todo/kbtd</span>"
|
2026-01-01 14:27:47 +08:00
|
|
|
|
|
|
|
|
# Then launch with:
|
|
|
|
|
./kbtd TODO.md</pre>
|
2026-01-01 14:35:19 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div id="app">
|
|
|
|
|
<div id="landing-screen" class="screen center-content">
|
|
|
|
|
<h1>Kanban TODO</h1>
|
|
|
|
|
<p>Markdown based task management</p>
|
|
|
|
|
<p>To get started, open a Markdown file containing your tasks.</p>
|
|
|
|
|
<button id="open-file-btn">Open TODO.md</button>
|
|
|
|
|
<div id="landing-instructions"></div>
|
2025-12-30 17:14:27 +08:00
|
|
|
<div id="landing-error" class="error" style="display:none"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="permission-screen" class="screen center-content">
|
|
|
|
|
<h1>Permission Required</h1>
|
2025-12-30 22:30:32 +08:00
|
|
|
<p>Kanban TODO needs permission to view and edit your file.</p>
|
2025-12-30 17:14:27 +08:00
|
|
|
<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>
|
2025-12-30 21:17:55 +08:00
|
|
|
<div class="new-project-form">
|
|
|
|
|
<input type="text" id="new-project-input" placeholder="New project name...">
|
|
|
|
|
<button id="create-project-btn">Create</button>
|
|
|
|
|
</div>
|
2025-12-30 17:14:27 +08:00
|
|
|
<button id="change-file-btn" style="margin-top: 1em">Change File</button>
|
2026-01-01 14:35:19 +08:00
|
|
|
<div id="section-instructions"></div>
|
2025-12-30 17:14:27 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="board-screen" class="screen">
|
|
|
|
|
<header>
|
|
|
|
|
<h2 id="board-title" style="margin:0">Project Name</h2>
|
2025-12-30 18:28:31 +08:00
|
|
|
<div class="header-controls">
|
|
|
|
|
<button id="toggle-completed-btn">Show Completed</button>
|
2025-12-30 21:17:55 +08:00
|
|
|
<button id="back-btn">Projects</button>
|
2025-12-30 18:28:31 +08:00
|
|
|
</div>
|
2025-12-30 17:14:27 +08:00
|
|
|
</header>
|
|
|
|
|
<div id="board-container" class="board"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-01-01 14:42:02 +08:00
|
|
|
const $ = document.querySelector.bind(document);
|
|
|
|
|
const $$ = document.querySelectorAll.bind(document);
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
const DB_NAME = 'kbtd';
|
|
|
|
|
const DB_VERSION = 1;
|
|
|
|
|
const STORE_NAME = 'fileHandles';
|
|
|
|
|
|
|
|
|
|
let state = {
|
|
|
|
|
fileHandle: null,
|
|
|
|
|
filePath: null,
|
2025-12-30 19:04:56 +08:00
|
|
|
sections: [],
|
2025-12-30 17:14:27 +08:00
|
|
|
currentSectionIndex: -1,
|
2025-12-30 18:28:31 +08:00
|
|
|
lastModified: 0,
|
|
|
|
|
showCompleted: false
|
2025-12-30 17:14:27 +08:00
|
|
|
};
|
|
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
let dragState = {
|
|
|
|
|
source: null,
|
|
|
|
|
currentDropTarget: null
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 14:52:41 +08:00
|
|
|
function getDataInt(el, key) {
|
|
|
|
|
return parseInt(el.dataset[key]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
function insertInstallInstructions(containerId) {
|
2026-01-01 14:42:02 +08:00
|
|
|
const template = $('#install-instructions-template');
|
2026-01-01 14:35:19 +08:00
|
|
|
const clone = template.content.cloneNode(true);
|
|
|
|
|
const currentUrl = window.location.href.split('?')[0];
|
|
|
|
|
clone.querySelector('.current-url').textContent = currentUrl;
|
2026-01-01 14:42:02 +08:00
|
|
|
$(`#${containerId}`).appendChild(clone);
|
2026-01-01 14:35:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
if (line.startsWith('# ')) {
|
|
|
|
|
finishCard();
|
|
|
|
|
currentSection = {
|
|
|
|
|
name: line.substring(2).trim(),
|
2025-12-30 21:24:48 +08:00
|
|
|
headerLineNumber: i,
|
2025-12-30 17:14:27 +08:00
|
|
|
columns: []
|
|
|
|
|
};
|
|
|
|
|
sections.push(currentSection);
|
|
|
|
|
currentColumn = null;
|
|
|
|
|
}
|
|
|
|
|
else if (line.startsWith('## ') && currentSection) {
|
|
|
|
|
finishCard();
|
|
|
|
|
currentColumn = {
|
|
|
|
|
name: line.substring(3).trim(),
|
2025-12-30 19:04:56 +08:00
|
|
|
headerLineNumber: i,
|
2025-12-30 17:14:27 +08:00
|
|
|
cards: []
|
|
|
|
|
};
|
|
|
|
|
currentSection.columns.push(currentColumn);
|
|
|
|
|
}
|
|
|
|
|
else if ((line.trim().startsWith('- [ ]') || line.trim().startsWith('- [x]')) && currentColumn) {
|
|
|
|
|
finishCard();
|
|
|
|
|
const isCompleted = line.trim().startsWith('- [x]');
|
|
|
|
|
const textStart = line.indexOf(']') + 2;
|
|
|
|
|
const text = line.substring(textStart).trim();
|
|
|
|
|
|
|
|
|
|
currentCard = {
|
|
|
|
|
text: text,
|
|
|
|
|
completed: isCompleted,
|
2025-12-30 19:04:56 +08:00
|
|
|
lineNumber: i,
|
|
|
|
|
originalLine: line,
|
2025-12-30 17:14:27 +08:00
|
|
|
nestedLines: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
if (currentCard) {
|
|
|
|
|
currentCard.nestedLines.push(line);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finishCard();
|
|
|
|
|
return sections;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 14:03:15 +08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
async function writeFile(text) {
|
|
|
|
|
try {
|
2026-01-01 14:03:15 +08:00
|
|
|
console.log('Writing file and updating UI');
|
|
|
|
|
reloadFromText(text);
|
|
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
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 updateLineInFile(lineNum, newText) {
|
|
|
|
|
console.log('updateLineInFile:', lineNum, newText);
|
|
|
|
|
const file = await state.fileHandle.getFile();
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
lines[lineNum] = newText;
|
|
|
|
|
await writeFile(lines.join('\n'));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
async function moveLineInFile(fromLine, toLine, targetColumnIndex, insertPosition) {
|
|
|
|
|
console.log('moveLineInFile: from line', fromLine, 'to line', toLine, 'targetColumnIndex:', targetColumnIndex, 'insertPosition:', insertPosition);
|
2025-12-30 19:04:56 +08:00
|
|
|
const file = await state.fileHandle.getFile();
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
const lines = text.split('\n');
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
const section = state.sections[state.currentSectionIndex];
|
|
|
|
|
const targetColumn = section.columns[targetColumnIndex];
|
|
|
|
|
const targetColumnIsEmpty = targetColumn.cards.length === 0;
|
2025-12-30 20:49:56 +08:00
|
|
|
const isInsertingAtBottom = insertPosition === targetColumn.cards.length;
|
2025-12-30 20:32:16 +08:00
|
|
|
|
2025-12-30 19:34:19 +08:00
|
|
|
console.log('Before move, source line', fromLine, '=', lines[fromLine]);
|
2025-12-30 20:32:16 +08:00
|
|
|
console.log('Target column is empty:', targetColumnIsEmpty);
|
2025-12-30 20:49:56 +08:00
|
|
|
console.log('Inserting at bottom:', isInsertingAtBottom);
|
2025-12-30 19:04:56 +08:00
|
|
|
|
|
|
|
|
const [movedLine] = lines.splice(fromLine, 1);
|
|
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
let actualInsertLine;
|
|
|
|
|
|
2025-12-30 19:34:19 +08:00
|
|
|
if (targetColumnIsEmpty) {
|
2025-12-30 20:32:16 +08:00
|
|
|
console.log('Moving to empty column - inserting after header');
|
|
|
|
|
actualInsertLine = targetColumn.headerLineNumber + 1;
|
2025-12-30 19:34:19 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
if (actualInsertLine < lines.length && lines[actualInsertLine].trim() === '') {
|
|
|
|
|
lines.splice(actualInsertLine, 0, movedLine);
|
2025-12-30 19:34:19 +08:00
|
|
|
} else {
|
2025-12-30 20:32:16 +08:00
|
|
|
lines.splice(actualInsertLine, 0, '', movedLine);
|
2025-12-30 19:34:19 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
const afterLine = actualInsertLine + (lines[actualInsertLine].trim() === '' ? 2 : 1);
|
2025-12-30 19:34:19 +08:00
|
|
|
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
|
|
|
|
|
lines.splice(afterLine, 0, '');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2025-12-30 20:32:16 +08:00
|
|
|
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, movedLine);
|
2025-12-30 19:34:19 +08:00
|
|
|
} else {
|
2025-12-30 20:32:16 +08:00
|
|
|
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--;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (actualInsertLine > 0 && lines[actualInsertLine - 1].trim() === '') {
|
|
|
|
|
console.log('Line before target is blank, replacing it');
|
|
|
|
|
lines[actualInsertLine - 1] = movedLine;
|
|
|
|
|
} else {
|
|
|
|
|
console.log('Inserting at target position');
|
|
|
|
|
lines.splice(actualInsertLine, 0, movedLine);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 20:49:56 +08:00
|
|
|
if (isInsertingAtBottom) {
|
|
|
|
|
const movedLineIndex = lines[actualInsertLine - 1] === movedLine ? actualInsertLine - 1 : actualInsertLine;
|
|
|
|
|
const afterLine = movedLineIndex + 1;
|
|
|
|
|
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, '');
|
|
|
|
|
}
|
2025-12-30 20:32:16 +08:00
|
|
|
}
|
2025-12-30 19:34:19 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('After move, lines around target:');
|
2025-12-30 20:32:16 +08:00
|
|
|
for (let i = Math.max(0, actualInsertLine - 2); i <= Math.min(lines.length - 1, actualInsertLine + 2); i++) {
|
2025-12-30 19:34:19 +08:00
|
|
|
console.log(` line ${i}: "${lines[i]}"`);
|
|
|
|
|
}
|
2025-12-30 19:04:56 +08:00
|
|
|
|
|
|
|
|
await writeFile(lines.join('\n'));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:17:55 +08:00
|
|
|
async function createProject(projectName) {
|
|
|
|
|
console.log('Creating project:', projectName);
|
|
|
|
|
const file = await state.fileHandle.getFile();
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await writeFile(newText);
|
|
|
|
|
|
|
|
|
|
const newSectionIndex = state.sections.findIndex(s => s.name === projectName);
|
|
|
|
|
if (newSectionIndex !== -1) {
|
|
|
|
|
await selectSection(newSectionIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:24:48 +08:00
|
|
|
async function createColumn(columnName) {
|
|
|
|
|
console.log('Creating column:', columnName);
|
|
|
|
|
const file = await state.fileHandle.getFile();
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
|
|
|
|
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, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await writeFile(lines.join('\n'));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 21:36:39 +08:00
|
|
|
async function createCard(columnIndex, cardText) {
|
|
|
|
|
console.log('Creating card in column', columnIndex, ':', cardText);
|
|
|
|
|
const file = await state.fileHandle.getFile();
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
await writeFile(lines.join('\n'));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
async function loadFile() {
|
|
|
|
|
try {
|
|
|
|
|
const file = await state.fileHandle.getFile();
|
|
|
|
|
state.lastModified = file.lastModified;
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
state.sections = parseMarkdown(text);
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
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); }
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-30 19:04:56 +08:00
|
|
|
|
|
|
|
|
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
|
|
|
|
|
renderBoard();
|
|
|
|
|
} else {
|
|
|
|
|
renderSectionPicker();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error loading file:', err);
|
|
|
|
|
alert('Error loading file. See console.');
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
2025-12-30 19:04:56 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showScreen(screenId) {
|
2026-01-01 14:42:02 +08:00
|
|
|
$$('.screen').forEach(el => el.classList.remove('active'));
|
|
|
|
|
$(`#${screenId}`).classList.add('active');
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSectionPicker() {
|
2026-01-01 14:42:02 +08:00
|
|
|
const list = $('#section-list');
|
2026-01-01 14:35:19 +08:00
|
|
|
list.replaceChildren(
|
|
|
|
|
...state.sections.map((section, index) =>
|
|
|
|
|
h('li', {},
|
|
|
|
|
h('button', {
|
|
|
|
|
className: 'section-btn',
|
|
|
|
|
textContent: section.name,
|
|
|
|
|
onclick: () => selectSection(index)
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#new-project-input').value = '';
|
2025-12-30 17:14:27 +08:00
|
|
|
showScreen('section-screen');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderBoard() {
|
|
|
|
|
const section = state.sections[state.currentSectionIndex];
|
|
|
|
|
if (!section) return renderSectionPicker();
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#board-title').textContent = section.name;
|
2025-12-30 18:28:31 +08:00
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
const toggleBtn = $('#toggle-completed-btn');
|
2025-12-30 18:28:31 +08:00
|
|
|
toggleBtn.textContent = state.showCompleted ? 'Hide Completed' : 'Show Completed';
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
const container = $('#board-container');
|
2026-01-01 14:35:19 +08:00
|
|
|
|
|
|
|
|
const columns = section.columns.map((column, colIndex) => {
|
|
|
|
|
const visibleCards = column.cards.filter(card =>
|
|
|
|
|
state.showCompleted || !card.completed
|
|
|
|
|
);
|
2025-12-30 20:32:16 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
const cardElements = [];
|
2025-12-30 20:32:16 +08:00
|
|
|
|
|
|
|
|
if (visibleCards.length > 0) {
|
2026-01-01 14:35:19 +08:00
|
|
|
cardElements.push(
|
|
|
|
|
h('div', {
|
|
|
|
|
className: 'drop-indicator',
|
|
|
|
|
dataset: { colIndex, position: 0 }
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-12-30 20:32:16 +08:00
|
|
|
}
|
2025-12-30 17:14:27 +08:00
|
|
|
|
|
|
|
|
column.cards.forEach((card, cardIndex) => {
|
2026-01-01 14:35:19 +08:00
|
|
|
if (!state.showCompleted && card.completed) return;
|
2025-12-30 18:28:31 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
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)
|
|
|
|
|
});
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
const checkbox = h('div', {
|
|
|
|
|
className: `card-checkbox ${card.completed ? 'checked' : ''}`,
|
|
|
|
|
onclick: async (e) => {
|
|
|
|
|
e.stopPropagation();
|
2026-01-01 14:52:41 +08:00
|
|
|
const lineNum = getDataInt(cardDiv, 'lineNumber');
|
2026-01-01 14:35:19 +08:00
|
|
|
const originalLine = cardDiv.dataset.originalLine;
|
|
|
|
|
const newLine = originalLine.includes('- [x]')
|
|
|
|
|
? originalLine.replace('- [x]', '- [ ]')
|
|
|
|
|
: originalLine.replace('- [ ]', '- [x]');
|
|
|
|
|
await updateLineInFile(lineNum, newLine);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
cardDiv.append(checkbox, h('span', { textContent: card.text }));
|
2025-12-30 20:32:16 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
cardElements.push(
|
|
|
|
|
cardDiv,
|
|
|
|
|
h('div', {
|
|
|
|
|
className: 'drop-indicator',
|
|
|
|
|
dataset: { colIndex, position: cardIndex + 1 }
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-12-30 17:14:27 +08:00
|
|
|
});
|
2025-12-30 20:32:16 +08:00
|
|
|
|
|
|
|
|
if (visibleCards.length === 0) {
|
2026-01-01 14:35:19 +08:00
|
|
|
cardElements.push(
|
|
|
|
|
h('div', {
|
|
|
|
|
className: 'drop-indicator',
|
|
|
|
|
dataset: { colIndex, position: 0 }
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-12-30 20:32:16 +08:00
|
|
|
}
|
2025-12-30 21:36:39 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
const cardList = h('div', {
|
|
|
|
|
className: 'card-list',
|
|
|
|
|
dataset: { colIndex },
|
|
|
|
|
ondragover: (e) => handleDragOver(e, colIndex, cardList),
|
|
|
|
|
ondrop: (e) => handleDrop(e, colIndex),
|
|
|
|
|
ondragleave: handleDragLeave
|
|
|
|
|
}, ...cardElements);
|
2025-12-30 21:36:39 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
const addCardForm = createAddForm('Card text...', async (text) => {
|
|
|
|
|
await createCard(colIndex, text);
|
|
|
|
|
addCardForm.hide();
|
2025-12-30 21:36:39 +08:00
|
|
|
addCardBtn.style.display = '';
|
2026-01-01 14:35:19 +08:00
|
|
|
});
|
2025-12-30 21:36:39 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
const addCardBtn = h('button', {
|
|
|
|
|
className: 'add-card-btn',
|
|
|
|
|
textContent: '+',
|
|
|
|
|
title: 'Add card',
|
|
|
|
|
onclick: () => {
|
|
|
|
|
addCardBtn.style.display = 'none';
|
|
|
|
|
addCardForm.show();
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-30 21:36:39 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
return h('div', {
|
|
|
|
|
className: 'column',
|
|
|
|
|
dataset: { colIndex }
|
|
|
|
|
},
|
|
|
|
|
h('div', { className: 'column-header', textContent: column.name }),
|
|
|
|
|
cardList,
|
|
|
|
|
h('div', { className: 'column-footer' }, addCardBtn, addCardForm)
|
|
|
|
|
);
|
2025-12-30 17:14:27 +08:00
|
|
|
});
|
2025-12-30 21:24:48 +08:00
|
|
|
|
2026-01-01 14:35:19 +08:00
|
|
|
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;
|
2025-12-30 21:24:48 +08:00
|
|
|
}
|
2026-01-01 14:35:19 +08:00
|
|
|
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);
|
2025-12-30 17:14:27 +08:00
|
|
|
showScreen('board-screen');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function selectSection(index) {
|
|
|
|
|
state.currentSectionIndex = index;
|
|
|
|
|
if (state.filePath) {
|
|
|
|
|
const key = `kbtd:${state.filePath}`;
|
2025-12-30 18:28:31 +08:00
|
|
|
const uiState = {
|
|
|
|
|
currentSection: state.sections[index].name,
|
|
|
|
|
showCompleted: state.showCompleted
|
|
|
|
|
};
|
2025-12-30 17:14:27 +08:00
|
|
|
localStorage.setItem(key, JSON.stringify(uiState));
|
|
|
|
|
}
|
|
|
|
|
renderBoard();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 18:28:31 +08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 14:52:41 +08:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
function handleDragStart(e, cardDiv) {
|
2025-12-30 20:32:16 +08:00
|
|
|
dragState.source = {
|
2026-01-01 14:52:41 +08:00
|
|
|
lineNumber: getDataInt(cardDiv, 'lineNumber'),
|
|
|
|
|
colIndex: getDataInt(cardDiv, 'colIndex'),
|
|
|
|
|
cardIndex: getDataInt(cardDiv, 'cardIndex')
|
2025-12-30 19:04:56 +08:00
|
|
|
};
|
2025-12-30 17:14:27 +08:00
|
|
|
e.target.classList.add('dragging');
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
2025-12-30 20:32:16 +08:00
|
|
|
e.dataTransfer.setData('text/plain', JSON.stringify(dragState.source));
|
|
|
|
|
console.log('Drag started:', dragState.source);
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDragEnd(e) {
|
|
|
|
|
e.target.classList.remove('dragging');
|
2025-12-30 20:32:16 +08:00
|
|
|
clearDropIndicators();
|
|
|
|
|
dragState.source = null;
|
|
|
|
|
dragState.currentDropTarget = null;
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
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;
|
2026-01-01 14:52:41 +08:00
|
|
|
const position = e.clientY < midpoint ? cardIndex : cardIndex + 1;
|
2025-12-30 20:32:16 +08:00
|
|
|
|
2026-01-01 14:52:41 +08:00
|
|
|
setActiveDropIndicator(colIndex, position);
|
2025-12-30 20:32:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDragOver(e, colIndex, cardList) {
|
2025-12-30 17:14:27 +08:00
|
|
|
e.preventDefault();
|
|
|
|
|
e.dataTransfer.dropEffect = 'move';
|
2025-12-30 20:32:16 +08:00
|
|
|
|
|
|
|
|
if (!dragState.source) return;
|
|
|
|
|
|
|
|
|
|
const cards = Array.from(cardList.querySelectorAll('.card'));
|
|
|
|
|
|
|
|
|
|
if (cards.length === 0) {
|
|
|
|
|
console.log('Drag over empty column', colIndex);
|
2026-01-01 14:52:41 +08:00
|
|
|
setActiveDropIndicator(colIndex, 0);
|
2025-12-30 20:32:16 +08:00
|
|
|
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) {
|
2026-01-01 14:52:41 +08:00
|
|
|
const cardIndex = getDataInt(closestCard, 'cardIndex');
|
2025-12-30 20:32:16 +08:00
|
|
|
const position = insertBefore ? cardIndex : cardIndex + 1;
|
|
|
|
|
|
|
|
|
|
if (!dragState.currentDropTarget ||
|
|
|
|
|
dragState.currentDropTarget.colIndex !== colIndex ||
|
|
|
|
|
dragState.currentDropTarget.position !== position) {
|
2026-01-01 14:52:41 +08:00
|
|
|
setActiveDropIndicator(colIndex, position);
|
2025-12-30 20:32:16 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDrop(e, targetColIndex) {
|
|
|
|
|
e.stopPropagation();
|
2025-12-30 19:04:56 +08:00
|
|
|
e.preventDefault();
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
clearDropIndicators();
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
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;
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
console.log('Drop: sourceCol', sourceColIndex, 'targetCol', targetColIndex, 'position', targetPosition);
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
if (sourceColIndex === targetColIndex) {
|
2025-12-30 20:32:16 +08:00
|
|
|
const sourceCardIndex = dragState.source.cardIndex;
|
|
|
|
|
if (targetPosition === sourceCardIndex || targetPosition === sourceCardIndex + 1) {
|
|
|
|
|
console.log('Same position, ignoring drop');
|
|
|
|
|
dragState.source = null;
|
|
|
|
|
dragState.currentDropTarget = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-30 19:04:56 +08:00
|
|
|
}
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 19:04:56 +08:00
|
|
|
const section = state.sections[state.currentSectionIndex];
|
|
|
|
|
const targetColumn = section.columns[targetColIndex];
|
2025-12-30 17:14:27 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
console.log('Target column:', targetColumn.name, 'position:', targetPosition);
|
2025-12-30 19:04:56 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
await moveLineInFile(sourceLineNumber, 0, targetColIndex, targetPosition);
|
2025-12-30 19:04:56 +08:00
|
|
|
|
2025-12-30 20:32:16 +08:00
|
|
|
dragState.source = null;
|
|
|
|
|
dragState.currentDropTarget = null;
|
2025-12-30 17:14:27 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function init() {
|
2026-01-01 14:35:19 +08:00
|
|
|
insertInstallInstructions('landing-instructions');
|
|
|
|
|
insertInstallInstructions('section-instructions');
|
2025-12-30 22:30:32 +08:00
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
state.filePath = params.get('file');
|
|
|
|
|
|
|
|
|
|
if (!state.filePath) {
|
|
|
|
|
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 {
|
|
|
|
|
showScreen('landing-screen');
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#landing-error').textContent =
|
2025-12-30 17:14:27 +08:00
|
|
|
`Please select the file: ${state.filePath}`;
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#landing-error').style.display = 'block';
|
2025-12-30 17:14:27 +08:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#open-file-btn').onclick = async () => {
|
2025-12-30 17:14:27 +08:00
|
|
|
try {
|
|
|
|
|
const [handle] = await window.showOpenFilePicker({
|
|
|
|
|
id: 'kbtd-todo-file',
|
|
|
|
|
mode: 'readwrite',
|
|
|
|
|
types: [{
|
|
|
|
|
description: 'Markdown files',
|
|
|
|
|
accept: { 'text/markdown': ['.md'] }
|
|
|
|
|
}]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
state.fileHandle = handle;
|
|
|
|
|
|
|
|
|
|
if (state.filePath) {
|
|
|
|
|
await storeFileHandle(state.filePath, handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await loadFile();
|
|
|
|
|
startPolling();
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err.name !== 'AbortError') {
|
|
|
|
|
console.error(err);
|
|
|
|
|
alert('Error opening file');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#grant-permission-btn').onclick = async () => {
|
2025-12-30 17:14:27 +08:00
|
|
|
if (state.fileHandle) {
|
|
|
|
|
if (await checkPermission(state.fileHandle)) {
|
|
|
|
|
await loadFile();
|
|
|
|
|
startPolling();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#back-btn').onclick = () => {
|
2025-12-30 17:14:27 +08:00
|
|
|
state.currentSectionIndex = -1;
|
|
|
|
|
renderSectionPicker();
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#toggle-completed-btn').onclick = () => {
|
2025-12-30 18:28:31 +08:00
|
|
|
toggleShowCompleted();
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#change-file-btn').onclick = () => {
|
|
|
|
|
$('#open-file-btn').click();
|
2025-12-30 17:14:27 +08:00
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#create-project-btn').onclick = async () => {
|
|
|
|
|
const input = $('#new-project-input');
|
2025-12-30 21:17:55 +08:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#new-project-input').onkeypress = (e) => {
|
2025-12-30 21:17:55 +08:00
|
|
|
if (e.key === 'Enter') {
|
2026-01-01 14:42:02 +08:00
|
|
|
$('#create-project-btn').click();
|
2025-12-30 21:17:55 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-30 17:14:27 +08:00
|
|
|
init();
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|