2629 lines
94 KiB
HTML
2629 lines
94 KiB
HTML
<!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 © 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>
|