├── LICENSE ├── README.md ├── api.php ├── index.html ├── scripts.js ├── setup-db.sh └── style.css /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ben Goriesky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tellor 2 | __Minimalist single-user (no auth) kanban todo app__ 3 | 4 | [Live Demo](https://tellor.cc/demo/?b=18486f63be6bb5f2) 5 | 6 | A Trello clone with a cleaner, simplified, and compact UI. Core essential features only, so no UI bloat. Can import boards from Trello. 7 | 8 | ## Features 9 | - Loads fast. Lightweight. Snappy controls 10 | - Clean and Compact UI 11 | - Single-user design. No authentication. 12 | - Import boards from Trello 13 | - Mobile friendly 14 | - Simple/minimal code that is easy to follow and modify 15 | - No libraries, frameworks, or dependencies. Just plain vanilla JS/PHP 16 | 17 | ### Requirements (LAMP) 18 | Apache / Nginx\ 19 | PHP 7+\ 20 | MySQL / MariaDB 21 | 22 | ### Setup 23 | - Clone repo into web directory 24 | - _(optional)_ Open `setup-db.sh` and set DB user options. Can be new user 25 | - _(optional)_ Copy DB creds into `api.php` line 6 26 | - Run `./setup-db.sh` as root to create database 27 | 28 | There are default credentials, so steps 2 and 3 are optional. 29 | 30 | ### Import / Export 31 | 32 | Tellor (and Trello) allow you to export boards to JSON.\ 33 | Tellor can import boards from either source.\ 34 | Tellor boards cannot be ported to Trello. 35 | 36 | ### Security 37 | This is a single user application without the hassle of authentication.\ 38 | But that means anyone who can find/access your board can modify it.\ 39 | This application is meant to be kept in a private web directory. Either on a local network server, or protected upstream by the reverse proxy. 40 | 41 | ### License 42 | [MIT](LICENSE) 43 | 44 | -------------------------------------------------------------------------------- /api.php: -------------------------------------------------------------------------------- 1 | set_charset('utf8mb4'); 8 | header("content-type: application/json"); 9 | header("cache-control: no-store"); 10 | 11 | switch ($_REQUEST['api']) { 12 | case 'getBoards': getBoards(); break; 13 | case 'getBoard': getBoard(); break; 14 | case 'newBoard': newBoard(); break; 15 | case 'editBoard': editBoard(); break; 16 | case 'addList': addList(); break; 17 | case 'renameList': renameList(); break; 18 | case 'moveList': moveList(); break; 19 | case 'newCard': newCard(); break; 20 | case 'addTag': addTag(); break; 21 | case 'getCard': getCard(); break; 22 | case 'saveCard': saveCard(); break; 23 | case 'moveCard': moveCard(); break; 24 | case 'deleteList': deleteList(); break; 25 | case 'delTag': delTag(); break; 26 | case 'cardColor': cardColor(); break; 27 | case 'archiveCard': archiveCard(); break; 28 | case 'deleteBoard': deleteBoard(); break; 29 | case 'export': export(); break; 30 | case 'import': import(); break; 31 | default: http_response_code(400); 32 | } 33 | $scon->close(); 34 | 35 | 36 | function getBoards() { //Get Boards 37 | global $scon; 38 | $res = $scon->query('SELECT * FROM boards'); 39 | if(!empty($res) && mysqli_num_rows($res) > 0) { 40 | $rows = mysqli_fetch_all($res, MYSQLI_ASSOC); 41 | echo json_encode($rows); 42 | } 43 | else echo '[]'; 44 | } 45 | 46 | 47 | function getCard() { //Get Card 48 | global $scon; 49 | $res = $scon->query('SELECT date_format(cdate,"%e %b %Y") AS cdate,date_format(mdate,"%e %b %Y") AS mdate,description FROM cards WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 50 | if(!empty($res) && mysqli_num_rows($res) == 1) { 51 | $row = mysqli_fetch_assoc($res); 52 | echo json_encode($row); 53 | } 54 | else http_response_code(404); 55 | } 56 | 57 | 58 | function getBoard() { //Get Board (lists + cards) 59 | global $scon; 60 | $res = $scon->query('SELECT 1 FROM boards WHERE id="'.$_REQUEST['bid'].'"'); 61 | if(!$res || !mysqli_num_rows($res)) { 62 | http_response_code(404); 63 | return; 64 | } 65 | $resL = $scon->query('SELECT id,ordr,name,0 as "start",0 as "end" FROM lists WHERE board="'.$_REQUEST['bid'].'" ORDER BY ordr asc'); 66 | $resC = $scon->query('SELECT list,id,parent,title,tags,color,IF(description IS NULL,null,1) AS description FROM cards WHERE board="'.$_REQUEST['bid'].'"'); 67 | $rowsL = mysqli_fetch_all($resL, MYSQLI_ASSOC); 68 | $rowsC = mysqli_fetch_all($resC, MYSQLI_ASSOC); 69 | $res = new stdClass(); 70 | $res->lists = $rowsL; 71 | $res->cards = $rowsC; 72 | echo json_encode($res); 73 | } 74 | 75 | 76 | function newBoard() { //New Board 77 | global $scon; 78 | $bid = idGen32(); 79 | $bgimg = $_REQUEST['imgurl'] ?: null; 80 | 81 | $sq = $scon->prepare('INSERT INTO boards(id,name,bgimg) VALUES(?,?,?)'); 82 | $sq->bind_param('sss', $bid, $_REQUEST['name'], $bgimg); 83 | $sq->execute(); 84 | $sq->close(); 85 | 86 | if($sq) echo $bid; 87 | else http_response_code(500); 88 | } 89 | 90 | 91 | function editBoard() { //Edit Board 92 | global $scon; 93 | $name = $_REQUEST['name']; 94 | 95 | $sq = $scon->prepare('UPDATE boards SET name=?,bgimg=? WHERE id=?'); 96 | $sq->bind_param('sss', $name, $_REQUEST['imgurl'], $_REQUEST['bid']); 97 | $sq->execute(); 98 | $sq->close(); 99 | 100 | if(!$sq) http_response_code(500); 101 | } 102 | 103 | 104 | function addList() { //Add List 105 | global $scon; 106 | $lid = idGen32(); 107 | 108 | $sq = $scon->prepare('INSERT INTO lists(board,id,ordr,name) VALUES("'.$_REQUEST['bid'].'","'.$lid.'",'.$_REQUEST['pos'].',?)'); 109 | $sq->bind_param('s', $_REQUEST['name']); 110 | $sq->execute(); 111 | $sq->close(); 112 | 113 | if($sq) echo $lid; 114 | else http_response_code(500); 115 | } 116 | 117 | 118 | function renameList() { //Rename List 119 | global $scon; 120 | $sq = $scon->prepare('UPDATE lists SET name=? WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['listid'].'" LIMIT 1'); 121 | $sq->bind_param('s', $_REQUEST['title']); 122 | $sq->execute(); 123 | $sq->close(); 124 | 125 | if(!$sq) http_response_code(500); 126 | } 127 | 128 | 129 | function moveList() { //Move List 130 | global $scon; 131 | $res = $scon->query('UPDATE lists SET ordr="'.$_REQUEST['ordr2'].'" WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['lid1'].'" LIMIT 1'); 132 | $res = $scon->query('UPDATE lists SET ordr="'.$_REQUEST['ordr1'].'" WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['lid2'].'" LIMIT 1'); 133 | if(!$res) 134 | http_response_code(500); 135 | } 136 | 137 | 138 | function newCard() { //Add Card 139 | global $scon; 140 | $cardid = idGen32(); 141 | $res = $scon->query('INSERT INTO cards(board,list,id,parent,title,tags,description) VALUES("'.$_REQUEST['bid'].'","'.$_REQUEST['listid'].'","'.$cardid.'","'.$_REQUEST['pid'].'","'.$_REQUEST['title'].'",null,null)'); 142 | if($res) echo $cardid; 143 | else http_response_code(500); 144 | } 145 | 146 | 147 | function addTag() { //Add Tag 148 | global $scon; 149 | $color = $_REQUEST['color']; 150 | if(preg_match("/^[a-zA-Z0-9#]{3,7}$/", $color)) { 151 | $res = $scon->query('UPDATE cards SET tags=CONCAT_WS(" ", tags, "'.$color.'") WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 152 | if(!$res) http_response_code(500); 153 | } 154 | else http_response_code(400); 155 | } 156 | 157 | 158 | function saveCard() { //Save Card 159 | global $scon; 160 | if($_SERVER['REQUEST_METHOD'] !== 'POST') {http_response_code(400); return;} 161 | $desc = file_get_contents('php://input') ?: null; 162 | $sq = $scon->prepare('UPDATE cards SET title=?,description=? WHERE board=? AND list=? AND id=? LIMIT 1'); 163 | $sq->bind_param('sssss', $_REQUEST['title'], $desc, $_REQUEST['bid'], $_REQUEST['listid'], $_REQUEST['cardid']); 164 | $sq->execute(); 165 | $sq->close(); 166 | 167 | if(!$sq) http_response_code(500); 168 | } 169 | 170 | 171 | function moveCard() { //Move Card 172 | global $scon; 173 | if($_REQUEST['stid'] == $_REQUEST['dpid'] || $_REQUEST['stid'] == $_REQUEST['dtid']) { 174 | http_response_code(400); 175 | return; 176 | } 177 | //set src pid to dest pid 178 | $scon->query('UPDATE cards SET parent="'.$_REQUEST['dpid'].'",list="'.$_REQUEST['dlid'].'" WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['stid'].'" LIMIT 1'); 179 | //set dest pid to src id 180 | if($_REQUEST['dtid'] != '0') 181 | $scon->query('UPDATE cards SET parent="'.$_REQUEST['stid'].'" WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['dtid'].'" LIMIT 1'); 182 | //set void pid to src pid 183 | if($_REQUEST['vtid'] != '0') 184 | $scon->query('UPDATE cards SET parent="'.$_REQUEST['spid'].'" WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['vtid'].'" LIMIT 1'); 185 | } 186 | 187 | 188 | function deleteList() { //Delete List 189 | global $scon; 190 | if($_SERVER['REQUEST_METHOD'] !== 'PUT') {http_response_code(400); return;} 191 | $res = $scon->query('INSERT INTO archive SELECT * FROM cards WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'"'); 192 | $res = $scon->query('DELETE FROM cards WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'"'); 193 | $res = $scon->query('DELETE FROM lists WHERE board="'.$_REQUEST['bid'].'" AND id="'.$_REQUEST['listid'].'"'); 194 | if(!$res) http_response_code(500); 195 | } 196 | 197 | 198 | function delTag() { //Delete Tag 199 | global $scon; 200 | $color = $_REQUEST['color']; 201 | $res = $scon->query('SELECT tags FROM cards WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 202 | if(empty($res) || mysqli_num_rows($res) != 1) { 203 | http_response_code(404); 204 | return; 205 | } 206 | $tag = mysqli_fetch_array($res)[0]; 207 | $tag = preg_replace("/\B$color\b\s?/", '', $tag); 208 | $tag = trim($tag); 209 | $tag = empty($tag) ? 'null' : '"'.$tag.'"'; 210 | 211 | $res = $scon->query('UPDATE cards SET tags='.$tag.' WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 212 | if(!$res) http_response_code(500); 213 | } 214 | 215 | 216 | function cardColor() { //Card Color 217 | global $scon; 218 | $color = (empty($_REQUEST['color']) || $_REQUEST['color'] === '#f0f0f0') ? 'null' : '"'.$_REQUEST['color'].'"'; 219 | $res = $scon->query('UPDATE cards SET color='.$color.' WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 220 | if(!$res) http_response_code(500); 221 | } 222 | 223 | 224 | function archiveCard() { //Archive Card 225 | global $scon; 226 | $res = $scon->query('INSERT INTO archive SELECT * FROM cards WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 227 | $res = $scon->query('UPDATE cards SET parent="'.$_REQUEST['pid'].'" WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND parent="'.$_REQUEST['cardid'].'" LIMIT 1'); 228 | $res = $scon->query('DELETE FROM cards WHERE board="'.$_REQUEST['bid'].'" AND list="'.$_REQUEST['listid'].'" AND id="'.$_REQUEST['cardid'].'" LIMIT 1'); 229 | if(!$res) 230 | http_response_code(500); 231 | } 232 | 233 | 234 | function deleteBoard() { //Delete Board 235 | global $scon; 236 | if($_SERVER['REQUEST_METHOD'] !== 'PUT') {http_response_code(400); return;} 237 | $scon->query('INSERT INTO archive SELECT * FROM cards WHERE board="'.$_REQUEST['bid'].'"'); 238 | $scon->query('DELETE FROM cards WHERE board="'.$_REQUEST['bid'].'"'); 239 | $scon->query('DELETE FROM lists WHERE board="'.$_REQUEST['bid'].'"'); 240 | $res = $scon->query('DELETE FROM boards WHERE id="'.$_REQUEST['bid'].'"'); 241 | if(!$res) http_response_code(404); 242 | } 243 | 244 | 245 | function export() { //Export Board 246 | global $scon; 247 | $res = $scon->query('SELECT * FROM boards WHERE id="'.$_REQUEST['bid'].'"'); 248 | if(empty($res) || mysqli_num_rows($res) != 1) { 249 | http_response_code(404); 250 | return; 251 | } 252 | $data = (object)mysqli_fetch_assoc($res); 253 | $resL = $scon->query('SELECT id,ordr,name FROM lists WHERE board="'.$_REQUEST['bid'].'" ORDER BY ordr asc'); 254 | $resC = $scon->query('SELECT list,id,parent,title,tags,color,cdate,mdate,description FROM cards WHERE board="'.$_REQUEST['bid'].'"'); 255 | $rowsL = mysqli_fetch_all($resL, MYSQLI_ASSOC); 256 | $rowsC = mysqli_fetch_all($resC, MYSQLI_ASSOC); 257 | $data->lists = $rowsL; 258 | $data->cards = $rowsC; 259 | 260 | header('content-disposition: attachment; filename="tellor_'.$data->id.'.json"'); 261 | echo json_encode($data); 262 | } 263 | 264 | 265 | function import() { //Import Board 266 | if($_SERVER['REQUEST_METHOD'] !== 'POST') {http_response_code(400); return;} 267 | $dataRaw = file_get_contents('php://input'); 268 | $data = json_decode($dataRaw); 269 | if(!$data) { 270 | http_response_code(400); 271 | return; 272 | } 273 | if(isset($data->nodeId)) importTrello($data); 274 | else if(isset($data->name)) importTellor($data); 275 | else http_response_code(400); 276 | } 277 | 278 | 279 | function importTellor($data) { //Import Tellor 280 | global $scon; 281 | $res = $scon->query('INSERT INTO boards(id,name,bgimg) VALUES("'.$data->id.'","'.$data->name.'","'.$data->bgimg.'")'); 282 | if(!$res) {http_response_code(500); return;} 283 | 284 | $sq = $scon->prepare('INSERT INTO lists(board,id,ordr,name) VALUES(?,?,?,?)'); 285 | foreach($data->lists as $list) { 286 | $sq->bind_param('ssssi', $data->id, $list->id, $list->ordr, $list->name); 287 | $sq->execute(); 288 | } 289 | $sq->close(); 290 | $sq = $scon->prepare('INSERT INTO cards VALUES(?,?,?,?,?,?,?,?,?,?)'); 291 | foreach($data->cards as $card) { 292 | $sq->bind_param('ssssssssss', $data->id, $card->list, $card->id, $card->parent, $card->title, $card->tags, $card->color, $card->cdate, $card->mdate, $card->description); 293 | $sq->execute(); 294 | } 295 | $sq->close(); 296 | echo $data->id; 297 | } 298 | 299 | 300 | function importTrello($data) { //Import Trello 301 | global $scon; 302 | $colorCodes = array("black" => "#000", "silver" => "#BBB", "gray" => "#888", "white" => "#FFF", "maroon" => "#900", "red" => "#F00", "purple" => "#808", "fuchsia" => "#F0F", "green" => "#080", "lime" => "#0F0", "olive" => "#880", "yellow" => "#FF0", "navy" => "#008", "blue" => "#00F", "teal" => "#088", "aqua" => "#0FF"); 303 | 304 | $bid = idGen32(); 305 | $boardName = str_replace(['\\','"','<','\n'], '', $data->name); 306 | $res = $scon->query('INSERT INTO boards(id,name,bgimg) VALUES("'.$bid.'","'.$boardName.'","'.$data->perfs->backgroundImage.'")'); 307 | if(!$res) {http_response_code(500); return;} 308 | 309 | usort($data->cards, function($a, $b) {return $a->pos - $b->pos;}); 310 | 311 | $sqlCards = $scon->prepare('INSERT INTO cards VALUES(?,?,?,?,?,?,null,default,?,?)'); 312 | $sqlLists = $scon->prepare('INSERT INTO lists(board,id,ordr,name) VALUES(?,?,?,?)'); 313 | 314 | foreach($data->lists as $list) { 315 | if($list->closed) continue; 316 | $lid = idGen32(); 317 | $sqlLists->bind_param('sssis', $bid, $lid, $list->pos, $list->name); 318 | $sqlLists->execute(); 319 | 320 | $pid = "0"; 321 | foreach($data->cards as $card) { 322 | if($card->closed || $card->idList !== $list->id) continue; 323 | $cid = idGen32(); 324 | 325 | $tags = ""; 326 | foreach($card->labels as $label) { //tags 327 | if(empty($label->color)) continue; 328 | if(str_starts_with($label->color, '#')) 329 | $tag = substr($label->color, 1); 330 | else 331 | $tag = $colorCodes[$label->color]; 332 | if($tag) 333 | $tags .= $tag . ' '; 334 | } 335 | $tags = trim($tags); 336 | 337 | $sqlCards->bind_param('ssssssss', $bid, $lid, $cid, $pid, $card->name, $tags, $card->dateLastActivity, $card->desc); 338 | $sqlCards->execute(); 339 | $pid = $cid; 340 | } 341 | } 342 | 343 | $sqlLists->close(); 344 | $sqlCards->close(); 345 | echo $bid; 346 | } 347 | 348 | 349 | function idGen32() { 350 | $chars = ''; 351 | $bytes = random_bytes(16); 352 | 353 | for($i = 0; $i < 16; $i++) { 354 | $byte = ord($bytes[$i]) & 31; 355 | $byte += ($byte > 9) ? 87 : 48; 356 | $chars .= chr($byte); 357 | } 358 | return $chars; 359 | } 360 | 361 | ?> 362 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tellor 8 | 9 | 10 | 11 |
12 | 15 |

Tellor

16 | 19 | 22 |
23 | 24 | 32 | 33 |
34 | 35 | 36 |
37 | 38 | 39 |
40 |

New Board

41 |
42 | Board Name

43 | BG Img URL

44 |
45 |
46 | 47 |
48 |
49 | 50 | 51 |
52 |

Edit Board

53 |
54 | Board Name

55 | BG Img URL

56 |
57 |
58 |
59 | 60 |
61 |
62 | 63 | 64 |
65 |

Import / Export

66 |
67 | Import board JSON from Tellor or Trello as new board

68 |
69 |
70 | Export current board as JSON 71 |
72 |
73 | 74 |
75 |
76 | 77 | 78 |
79 |

80 |   
Tags
81 |
82 |
83 |
84 |
Description
85 |
86 | 87 |
88 |
89 | 90 | 91 |
92 | 93 |
94 |
95 | 96 |
97 | 98 | 99 | -------------------------------------------------------------------------------- /scripts.js: -------------------------------------------------------------------------------- 1 | var boardsJSON, currentBoard, tags, lists, activeCard, listMax, defaultTextColor = '#f0f0f0'; 2 | var tagPalette = ['#900', '#F80', '#DD0', '#090', '#0DD', '#00B', '#80F', '#F08', '#E0E', '#F8F', '#000', '#FFF', '#888']; 3 | 4 | const getCookie = (cookie) => (document.cookie.match('(^|;)\\s*'+cookie+'\\s*=\\s*([^;]+)')?.pop()||''); 5 | function setCookie(cookie, value, del=false) { 6 | var date = new Date(); 7 | if(del) date.setTime(1); 8 | else date.setTime(date.getTime() + (120*24*60*60*1000)); //120 days 9 | document.cookie = cookie + '=' + value + '; expires=' + date.toUTCString() + '; SameSite=Lax'; 10 | } 11 | 12 | 13 | //BOARD HOT LINK 14 | var urlParams = new URLSearchParams(window.location.search); 15 | var boardID = urlParams.get('b') || getCookie('bid') || null; 16 | var menuState = getCookie('menuState') || 'open'; 17 | 18 | 19 | getBoards(); 20 | if(boardID) 21 | changeBoard(boardID, true); 22 | if(menuState === 'closed' && window.innerWidth > 1000) 23 | toggleMenu('close'); 24 | if(getCookie('orientation')) 25 | scrollOrientation(); 26 | populateTagColors(); 27 | 28 | 29 | //POP STATE 30 | window.onkeyup = (e) => {if(e.key === 'Escape') {closeCard();}}; 31 | window.onpopstate = function(event) { 32 | if(event.state && !activeCard) { 33 | route('home'); 34 | if(event.state !== boardID) 35 | changeBoard(event.state, true); 36 | } 37 | else 38 | closeCard(); 39 | }; 40 | 41 | 42 | //ROUTE 43 | function route(_route) { 44 | if(_route === 'deleteBoard') { 45 | saveBoardBtn.style.display = delChk.checked ? 'none' : 'block'; 46 | delBoardBtn.style.display = delChk.checked ? 'block' : 'none'; 47 | return; 48 | } 49 | 50 | activeCard = 0; 51 | popupBG.style.display = 'none'; 52 | newBoardBox.style.display = 'none'; 53 | editBoardBox.style.display = 'none'; 54 | importExportBox.style.display = 'none'; 55 | viewCardBox.style.display = 'none'; 56 | saveBoardBtn.style.display = 'block'; 57 | delBoardBtn.style.display = 'none'; 58 | delChk.checked = false; 59 | 60 | if(_route === 'home') 61 | return; 62 | if(_route === 'newBoard') { 63 | popupBG.style.display = 'flex'; 64 | newBoardBox.style.display = 'block'; 65 | newBoardName.value = null; 66 | newbgimgurl.value = null; 67 | newBoardName.focus(); 68 | } 69 | if(_route === 'editBoard') { 70 | if(!boardID) return; 71 | popupBG.style.display = 'flex'; 72 | editBoardBox.style.display = 'block'; 73 | boardName.value = currentBoard.name; 74 | bgimgurl.value = currentBoard.bgimg; 75 | boardName.focus(); 76 | } 77 | if(_route === 'importExport') { 78 | popupBG.style.display = 'flex'; 79 | importExportBox.style.display = 'block'; 80 | importFile.disabled = false; 81 | loader.style.display = 'none'; 82 | } 83 | if(_route === 'viewCard') { 84 | popupBG.style.display = 'flex'; 85 | viewCardBox.style.display = 'flex'; 86 | tagsBox.innerHTML = ''; 87 | cardDescTA.style.display = 'none'; 88 | cardDescDiv.style.display = 'block'; 89 | addTagBox.style.height = 0; 90 | } 91 | if(history.state) 92 | history.pushState(null, '', ''); 93 | } 94 | 95 | 96 | //GET BOARDS 97 | function getBoards() { 98 | var xhttp = new XMLHttpRequest(); 99 | xhttp.onloadend = function() { 100 | if(this.status === 200) { 101 | boardsJSON = JSON.parse(this.responseText); 102 | for(b of boardsJSON) { 103 | let option = document.createElement("option"); //select menu option 104 | option.text = b.name; 105 | option.value = b.id; 106 | boardsSelect.appendChild(option); 107 | let menuBtn = document.createElement("a"); //side menu button 108 | menuBtn.href = "?b=" + b.id; 109 | menuBtn.setAttribute('onclick', "event.preventDefault();changeBoard('"+b.id+"',false)"); 110 | menuBtn.textContent = b.name; 111 | boards.appendChild(menuBtn); 112 | } 113 | if(boardID) { //default board set, changeBoard will/has run 114 | currentBoard = boardsJSON.find(e => e.id === boardID); 115 | if(currentBoard && lists !== undefined) { //board exists and changeBoard resolved first (race cond) 116 | setActiveBoardBtn(); 117 | history.replaceState(boardID, '', '?b=' + boardID); 118 | document.title = currentBoard.name + ' | Tellor'; 119 | main.style.backgroundImage = currentBoard.bgimg ? 'url(' + currentBoard.bgimg + ')' : null; 120 | } 121 | } 122 | } 123 | else alert('Error: ' + this.status); 124 | } 125 | xhttp.open('GET', 'api.php?api=getBoards', true); 126 | xhttp.send(); 127 | } 128 | 129 | 130 | //NEW BOARD 131 | function newBoard() { 132 | var bname = newBoardName.value.trim(); 133 | if(!bname) return; 134 | 135 | var imgurl = newbgimgurl.value.trim(); 136 | if(imgurl && !URL.canParse(imgurl)) {alert('Invalid URL'); return;} 137 | 138 | var xhttp = new XMLHttpRequest(); 139 | xhttp.onloadend = function() { 140 | if(this.status === 200) { 141 | let option = document.createElement("option"); //select menu option 142 | option.text = bname; 143 | option.value = this.responseText; 144 | boardsSelect.appendChild(option); 145 | let menuBtn = document.createElement("a"); //side menu button 146 | menuBtn.href = "?b=" + this.responseText; 147 | menuBtn.setAttribute('onclick', "event.preventDefault();changeBoard('"+this.responseText+"',false)"); 148 | menuBtn.textContent = bname; 149 | boards.appendChild(menuBtn); 150 | boardsJSON.push({id:this.responseText, name:bname, bgimg:imgurl}); 151 | route('home'); 152 | changeBoard(this.responseText, false); 153 | } 154 | else alert('Error: ' + this.status); 155 | } 156 | xhttp.open('GET', 'api.php?api=newBoard&name=' + encodeURIComponent(bname) + '&imgurl=' + encodeURIComponent(imgurl), true); 157 | xhttp.send(); 158 | } 159 | 160 | 161 | //EDIT BOARD 162 | function editBoard() { 163 | var bname = boardName.value; 164 | if(!bname) return; 165 | 166 | var imgurl = bgimgurl.value; 167 | if(imgurl && !URL.canParse(imgurl)) {alert('Invalid URL'); return;} 168 | 169 | var xhttp = new XMLHttpRequest(); 170 | xhttp.onloadend = function() { 171 | if(this.status === 200) { 172 | currentBoard.name = bname; 173 | currentBoard.bgimg = imgurl; 174 | route('home'); 175 | var btn = boards.querySelector('a[href="?b='+boardID+'"'); //side menu button 176 | if(btn) btn.textContent = bname; 177 | btn = boardsSelect.querySelector('option[value="'+boardID+'"'); //select menu option 178 | if(btn) btn.textContent = bname; 179 | document.title = bname + ' | Tellor'; 180 | main.style.backgroundImage = imgurl ? 'url(' + imgurl + ')' : null; 181 | } 182 | else alert('Error: ' + this.status); 183 | } 184 | xhttp.open('GET', 'api.php?api=editBoard&bid=' + boardID + '&name=' + encodeURIComponent(bname) + '&imgurl=' + encodeURIComponent(imgurl), true); 185 | xhttp.send(); 186 | } 187 | 188 | 189 | //CHANGE BOARD 190 | function changeBoard(bid, popState) { 191 | if(!bid) return; 192 | 193 | var xhttp = new XMLHttpRequest(); 194 | xhttp.onloadend = function() { 195 | if(this.status === 200) { 196 | var board = JSON.parse(this.responseText); 197 | boardID = bid; 198 | if(boardsJSON) //skip only if getBoards has not loaded yet (race cond) 199 | currentBoard = boardsJSON.find(e => e.id === bid); 200 | lists = board.lists; 201 | render(board.cards); 202 | setCookie('bid', bid, false); 203 | if(!history.state) //init load. set history.state 204 | history.replaceState(bid, '', '?b=' + bid); 205 | if(!popState) //nav via menu, not history 206 | history.pushState(bid, '', '?b=' + bid); 207 | if(window.innerWidth < 1000) //close menu on mobile 208 | toggleMenu('open'); //mobile menu state is reversed. so to be closed by default 209 | delete board.cards; 210 | } 211 | else { 212 | boardID = currentBoard = main.style.backgroundImage = null; 213 | main.innerHTML = 'Error getting board: ' + this.status; 214 | } 215 | if(boardsJSON) { //skip only if getBoards has not loaded yet (race cond) 216 | setActiveBoardBtn(); 217 | document.title = currentBoard ? currentBoard.name + ' | Tellor' : 'Tellor'; 218 | } 219 | } 220 | xhttp.open('GET', 'api.php?api=getBoard&bid=' + bid, true); 221 | xhttp.send(); 222 | } 223 | 224 | 225 | //Set Active Board Btn 226 | function setActiveBoardBtn() { 227 | boardsSelect.value = boardID || ''; 228 | var menuBtns = boards.querySelectorAll('a'); 229 | for(e of menuBtns) { 230 | if(e.search === '?b=' + boardID) 231 | e.classList.add('activeBoard'); 232 | else 233 | e.classList.remove('activeBoard'); 234 | } 235 | } 236 | 237 | 238 | //Find Card 239 | function findCard(cards, pid, start, end) { 240 | for(let i = start; i < end; i++) { 241 | if(cards[i].parent === pid) 242 | return cards[i]; 243 | } 244 | return false; 245 | } 246 | 247 | 248 | //RENDER 249 | function render(cards) { 250 | main.innerHTML = ''; 251 | lists.sort((a,b) => +a.ordr - b.ordr); 252 | listMax = 0; 253 | if(currentBoard) 254 | main.style.backgroundImage = currentBoard.bgimg ? 'url(' + currentBoard.bgimg + ')' : null; 255 | 256 | //find list boundaries in cards list. this scales better than .find 257 | var currentList, clID, clStart = 0; 258 | for(let i = 0; i < cards.length; i++) { 259 | if(cards[i].list !== clID) { 260 | clStart = i; 261 | clID = cards[i].list; 262 | if(currentList) 263 | currentList.end = i; 264 | currentList = lists.find(e => e.id === clID); 265 | currentList.start = clStart; 266 | } 267 | } 268 | if(currentList) 269 | currentList.end = cards.length; 270 | 271 | for(l of lists) { //iterate lists 272 | listMax = +l.ordr; 273 | let newList = document.createElement('div'); 274 | newList.classList.add('list'); 275 | newList.setAttribute('ondrop', "dragDrop(event)"); 276 | newList.innerHTML = `
${l.name}
+ Add Card
`; 277 | main.appendChild(newList); 278 | 279 | let cardsContainer = document.getElementById('cc' + l.id); 280 | let card = findCard(cards, '0', l.start, l.end); //find first card 281 | while(card) { 282 | let newCard = document.createElement('div'); //card 283 | newCard.classList.add('card'); 284 | newCard.id = card.id; 285 | if(card.description) 286 | newCard.classList.add('hasDescription'); 287 | if(card.color) 288 | newCard.style.color = card.color; 289 | newCard.setAttribute('onclick', "viewCard('"+card.id+"')"); 290 | newCard.setAttribute('draggable', true); newCard.setAttribute('ondragstart', "dragStart(event)"); newCard.setAttribute('ondragend', "dragEnd(event)"); newCard.setAttribute('ondragenter', "dragEnter(event)"); 291 | let tagsDiv = document.createElement('div'); //tags 292 | tagsDiv.id = 'tags' + card.id; 293 | tagsDiv.classList.add('tags'); 294 | let _tags = card.tags?.split(' '); //color tags 295 | if(_tags && _tags != 0 && _tags[0] != '') { 296 | for(tagColor of _tags) { 297 | let colorTag = document.createElement('div'); 298 | colorTag.style.background = tagColor; 299 | colorTag.setAttribute('color', tagColor); 300 | tagsDiv.appendChild(colorTag); 301 | } 302 | } 303 | newCard.appendChild(tagsDiv); 304 | newCard.append(card.title); 305 | cardsContainer.appendChild(newCard); 306 | 307 | card = findCard(cards, card.id, l.start, l.end); //find next card 308 | } 309 | } 310 | 311 | main.innerHTML += `
Add List

`; 312 | } 313 | 314 | 315 | //ADD LIST 316 | function addList() { 317 | var lname = newListName.value.trim(); 318 | if(!lname) return; 319 | lname = lname.replaceAll('<', '<'); 320 | 321 | var xhttp = new XMLHttpRequest(); 322 | xhttp.onloadend = function() { 323 | if(this.status === 200) { 324 | listMax += 1; 325 | lists.push({id:this.responseText, name:lname, color:null, ordr:listMax}); 326 | let newList = document.createElement('div'); 327 | newList.classList.add('list'); 328 | newList.setAttribute('ondrop', "dragDrop(event)"); 329 | newList.innerHTML = `
${lname}
+ Add Card
`; 330 | newListColumn.insertAdjacentElement('beforeBegin', newList); 331 | newListName.value = null; 332 | main.scrollLeft = main.scrollWidth; 333 | } 334 | } 335 | xhttp.open('GET', 'api.php?api=addList&bid=' + boardID + '&name=' + encodeURIComponent(lname) + '&pos=' + (listMax + 1), true); 336 | xhttp.send(); 337 | } 338 | 339 | 340 | //ADD CARD 341 | function newCard(listID) { 342 | if(!listID) return; 343 | 344 | var cardsContainer = document.getElementById('cc' + listID); 345 | var lastCard = cardsContainer.lastElementChild; 346 | var pid = lastCard ? lastCard.id : '0'; 347 | var _cardTitle = 'New Card'; 348 | 349 | var newCard = document.createElement('div'); 350 | newCard.id = 'new'; //tmp id 351 | newCard.classList.add('card'); 352 | newCard.setAttribute('draggable', true); newCard.setAttribute('ondragstart', "dragStart(event)"); newCard.setAttribute('ondragend', "dragEnd(event)"); newCard.setAttribute('ondragenter', "dragEnter(event)"); 353 | var newCardTags = document.createElement('div'); 354 | newCardTags.id = 'tagsnew'; //tmp id 355 | newCardTags.classList.add('tags'); 356 | newCard.appendChild(newCardTags); 357 | newCard.appendChild(document.createTextNode(_cardTitle)); 358 | cardsContainer.appendChild(newCard); 359 | cardsContainer.scrollTop = cardsContainer.scrollHeight; 360 | 361 | var xhttp = new XMLHttpRequest(); 362 | xhttp.onloadend = function() { 363 | if(this.status === 200) { 364 | newCard.id = activeCard.id = this.responseText; //real id 365 | newCardTags.id = 'tags' + this.responseText; //real id 366 | newCard.setAttribute('onclick', "viewCard('"+this.responseText+"')"); 367 | } 368 | else newCard.remove(); 369 | } 370 | 371 | xhttp.open('GET', 'api.php?api=newCard&bid=' + boardID + '&listid=' + listID + '&pid=' + pid + '&title=' + encodeURIComponent(_cardTitle), true); 372 | xhttp.send(); 373 | viewCard('new'); 374 | window.getSelection().selectAllChildren(cardTitle); 375 | } 376 | 377 | 378 | //CLOSE POPUPS 379 | function closeCard() { 380 | if(activeCard) { //save before route to preserve card details 381 | if(activeCard.id === 'new') { //retry save until new card id has resolved 382 | setTimeout(closeCard, 100); 383 | return; 384 | } 385 | saveCard(); 386 | } 387 | route('home'); 388 | } 389 | 390 | 391 | //VIEW CARD DETAILS 392 | function viewCard(cardID) { 393 | route('viewCard'); 394 | 395 | var card = document.getElementById(cardID); 396 | cardTitle.textContent = card.textContent; //get title from card tile 397 | cardDescTA.value = cardDescDiv.innerHTML = cdate.textContent = mdate.textContent = ''; //clear description 398 | var listID = card.parentElement.id.substring(2); 399 | var color = card.style.color ? rgb2hex(card.style.color) : defaultTextColor; //card text color 400 | cardTextColorPicker.value = color; 401 | var cardTags = document.getElementById('tags' + cardID); //get tags from card tile 402 | var _tags = [...cardTags.children].map(e => e.attributes.color.value); 403 | if(_tags && _tags != 0) { 404 | for(tagColor of _tags) { 405 | var newTag = document.createElement('div'); 406 | newTag.style.background = tagColor; 407 | newTag.setAttribute('onclick', "delTag(this,'"+tagColor+"')"); 408 | addTagBtn.insertAdjacentElement('beforebegin', newTag); 409 | } 410 | } 411 | 412 | var xhttp = new XMLHttpRequest(); 413 | xhttp.onloadend = function() { 414 | if(this.status === 200) { 415 | const descJSON = JSON.parse(this.responseText); 416 | cdate.textContent = descJSON.cdate; 417 | mdate.textContent = descJSON.mdate; 418 | activeCard.description = cardDescTA.value = descJSON.description; 419 | parseDescription(); 420 | } 421 | } 422 | 423 | if(cardID !== 'new') { 424 | xhttp.open('GET', 'api.php?api=getCard&bid=' + boardID + '&listid=' + listID + '&cardid=' + cardID, true); 425 | xhttp.send(); 426 | } 427 | activeCard = {id: cardID, title: card.textContent, color: color, list: listID, description: ''}; 428 | } 429 | 430 | 431 | //SAVE CARD DETAILS 432 | function saveCard() { 433 | if(!activeCard) return; 434 | var card = document.getElementById(activeCard.id); 435 | var title = cardTitle.textContent.trim(); 436 | if(!title) { //revert empty title 437 | cardTitle.textContent = activeCard.title; 438 | return; 439 | } 440 | 441 | var xhttp = new XMLHttpRequest(); 442 | xhttp.onloadend = function() { 443 | if(this.status === 200) { 444 | card.lastChild.textContent = title; 445 | if(activeCard) { 446 | activeCard.title = title; 447 | activeCard.description = cardDescTA.value; 448 | mdate.textContent = new Date().toLocaleDateString("en-gb", {day: 'numeric', month: 'short', year: 'numeric'}); 449 | } 450 | } 451 | } 452 | if(activeCard.title != title || (activeCard.description || '') != cardDescTA.value) { //title or descrption changed 453 | if(activeCard.id === 'new') { //retry save until new card id has resolved. skip if no change made 454 | setTimeout(saveCard, 100); 455 | return; 456 | } 457 | xhttp.open('POST', 'api.php?api=saveCard&bid=' + boardID + '&listid=' + activeCard.list + '&cardid=' + activeCard.id + '&title=' + encodeURIComponent(title), true); 458 | xhttp.send(cardDescTA.value); 459 | 460 | if(cardDescTA.value) card.classList.add('hasDescription'); 461 | else card.classList.remove('hasDescription'); 462 | } 463 | } 464 | 465 | 466 | //EDIT DESCRIPTION 467 | function editDescription(event) { //onClick 468 | if(event.target.nodeName == 'A') //enable link click w/o starting edit 469 | return; 470 | if(window.getSelection().anchorOffset - window.getSelection().focusOffset) //allow highlight description w/o starting edit 471 | return; 472 | cardDescDiv.style.display = 'none'; 473 | cardDescTA.style.display = 'block'; 474 | cardDescTA.style.height = 0; 475 | cardDescTA.style.height = cardDescTA.scrollHeight + 2 + 'px'; //+2 eliminates scrollbar 476 | cardDescTA.focus(); 477 | } 478 | 479 | //EDIT DESCRIPTION FINISHED 480 | function doneEditDescription() { //onBlur 481 | cardDescDiv.style.display = 'block'; 482 | cardDescTA.style.display = 'none'; 483 | if(!activeCard) return; //saved and closed already 484 | parseDescription(); 485 | } 486 | 487 | //CANCEL DESCRIPTION 488 | function cancelCard() { 489 | cardDescTA.value = activeCard.description; 490 | parseDescription(); 491 | } 492 | 493 | //PARSE DESCRIPTION 494 | function parseDescription() { 495 | var parsedDesc = cardDescTA.value.replaceAll('<', '<'); //html -> text 496 | parsedDesc = parsedDesc.replace(/\[([^\]]+)\]\((http[^)]+)\)/g, '$1'); //md links -> html 497 | parsedDesc = parsedDesc.replace(/(?$1'); //links -> html 498 | cardDescDiv.innerHTML = parsedDesc; 499 | } 500 | 501 | 502 | //DELETE BOARD 503 | function deleteBoard() { 504 | if(!boardID) return; 505 | 506 | var xhttp = new XMLHttpRequest(); 507 | xhttp.onloadend = function() { 508 | if(this.status === 200) { 509 | setCookie('bid', 0, true); 510 | window.location = '?b='; 511 | } 512 | else alert('Error: ' + this.status); 513 | } 514 | 515 | if(confirm('DELETE Entire Board?\n' + currentBoard.name)) { 516 | xhttp.open('PUT', 'api.php?api=deleteBoard&bid=' + boardID, true); 517 | xhttp.send(); 518 | } 519 | } 520 | 521 | 522 | //ADD TAG 523 | function addTag(color) { 524 | if(!color) return; 525 | var card = document.getElementById(activeCard.id); 526 | var preExisting = card.querySelector('div[color="'+color+'"]'); 527 | if(preExisting) return; 528 | 529 | var xhttp = new XMLHttpRequest(); 530 | xhttp.onloadend = function() { 531 | if(this.status === 200) { 532 | var newTag = document.createElement('div'); //view card details tag 533 | newTag.style.background = color; 534 | newTag.setAttribute('onclick', "delTag(this,'"+color+"')"); 535 | addTagBtn.insertAdjacentElement('beforebegin', newTag); 536 | 537 | newTag = document.createElement('div'); //card tag 538 | newTag.style.background = color; 539 | newTag.setAttribute('color', color); 540 | document.getElementById('tags' + activeCard.id).appendChild(newTag); 541 | 542 | addTagBox.style.height = 0; 543 | } 544 | } 545 | xhttp.open('GET', 'api.php?api=addTag&bid=' + boardID + '&listid=' + activeCard.list + '&cardid=' + activeCard.id + '&color=' + encodeURIComponent(color), true); 546 | xhttp.send(); 547 | } 548 | 549 | 550 | //DELETE TAG 551 | function delTag(elem, color) { 552 | if(!color) return; 553 | var card = document.getElementById(activeCard.id); 554 | var colorTag = card.querySelector('div[color="'+color+'"]'); 555 | if(!colorTag) return; 556 | 557 | var xhttp = new XMLHttpRequest(); 558 | xhttp.onloadend = function() { 559 | if(this.status === 200) { 560 | elem.remove(); 561 | colorTag.remove(); 562 | addTagBox.style.height = 0; 563 | } 564 | else alert('Error: ' + this.status); 565 | } 566 | xhttp.open('GET', 'api.php?api=delTag&bid=' + boardID + '&listid=' + activeCard.list + '&cardid=' + activeCard.id + '&color=' + encodeURIComponent(color), true); 567 | xhttp.send(); 568 | } 569 | 570 | 571 | //RENAME LIST 572 | function renameList(titleElem) { 573 | if(!titleElem) return; 574 | var listTitle = titleElem.innerText.trim(); 575 | listTitle = listTitle.replaceAll('<', '<'); 576 | var listID = titleElem.parentElement.id; 577 | var list = lists.find(e => e.id === listID); 578 | if(!listTitle) { 579 | titleElem.innerText = list.name; 580 | return; 581 | } 582 | if(listTitle.length > 1023) { 583 | alert('Error:\nTitle length: ' + listTitle.length + '\nRequired: 1 - 1023'); 584 | return; 585 | } 586 | 587 | var xhttp = new XMLHttpRequest(); 588 | xhttp.onloadend = function() { 589 | if(this.status === 200) { 590 | list.name = listTitle; 591 | } 592 | else { 593 | titleElem.innerText = list.name; 594 | alert('Error: ' + this.status); 595 | } 596 | } 597 | if(listTitle !== list.name) { //only save if name changed 598 | xhttp.open('GET', 'api.php?api=renameList&bid=' + boardID + '&listid=' + listID + '&title=' + encodeURIComponent(listTitle), true); 599 | xhttp.send(); 600 | } 601 | } 602 | 603 | 604 | //MOVE LIST 605 | function moveList(listTitle, direction) { 606 | if(!listTitle || !direction) return; 607 | 608 | var listContainer = listTitle.parentElement; 609 | var swap = (direction === 'right') ? listContainer.nextElementSibling : listContainer.previousElementSibling; 610 | if(!swap || !swap.classList.contains('list') || !listContainer.classList.contains('list')) return; 611 | 612 | var swapTitle = swap.firstChild; 613 | var swapOrdr = swapTitle.getAttribute('ordr'); 614 | var listOrdr = listTitle.getAttribute('ordr'); 615 | 616 | //move list 617 | listTitle.setAttribute('ordr', swapOrdr); 618 | swapTitle.setAttribute('ordr', listOrdr); 619 | var swapDir = (direction === 'right') ? 'beforebegin' : 'afterend'; 620 | listContainer.insertAdjacentElement(swapDir, swap); 621 | 622 | var xhttp = new XMLHttpRequest(); 623 | xhttp.onloadend = function() { 624 | if(this.status !== 200) { //revert move 625 | listTitle.setAttribute('ordr', listOrdr); 626 | swapTitle.setAttribute('ordr', swapOrdr); 627 | swapDir = (direction !== 'right') ? 'beforebegin' : 'afterend'; 628 | listContainer.insertAdjacentElement(swapDir, swap); 629 | } 630 | } 631 | xhttp.open('GET', 'api.php?api=moveList&bid=' + boardID + '&lid1=' + listTitle.id + '&lid2=' + swapTitle.id + '&ordr1=' + listOrdr + '&ordr2=' + swapOrdr, true); 632 | xhttp.send(); 633 | } 634 | 635 | 636 | //DELETE LIST 637 | function deleteList(titleElem) { 638 | if(!titleElem) return; 639 | var listID = titleElem.id; 640 | 641 | var xhttp = new XMLHttpRequest(); 642 | xhttp.onloadend = function() { 643 | if(this.status === 200) { 644 | titleElem.parentElement.remove(); 645 | } 646 | else alert('Error: ' + this.status); 647 | } 648 | 649 | if(confirm('Delete List and all cards in it?\n' + titleElem.firstChild.textContent)) { 650 | xhttp.open('PUT', 'api.php?api=deleteList&bid=' + boardID + '&listid=' + listID, true); 651 | xhttp.send(); 652 | } 653 | } 654 | 655 | 656 | //CARD COLOR 657 | function cardColor(color=false) { 658 | color = color ? defaultTextColor : cardTextColorPicker.value; 659 | if(color === activeCard.color) 660 | return; 661 | var card = document.getElementById(activeCard.id); 662 | card.style.color = cardTextColorPicker.value = color; 663 | 664 | var xhttp = new XMLHttpRequest(); 665 | xhttp.onloadend = function() { 666 | if(this.status === 200) 667 | activeCard.color = color; 668 | else 669 | card.style.color = cardTextColorPicker.value = activeCard.color; 670 | } 671 | xhttp.open('GET', 'api.php?api=cardColor&bid=' + boardID + '&listid=' + activeCard.list + '&cardid=' + activeCard.id + '&color=' + encodeURIComponent(color), true); 672 | xhttp.send(); 673 | } 674 | 675 | 676 | //ARCHIVE CARD 677 | function archiveCard() { 678 | if(!activeCard || activeCard.id === 'new') return; 679 | var card = document.getElementById(activeCard.id); 680 | if(!card) return; 681 | var pid = card.previousElementSibling ? card.previousElementSibling.id : '0'; 682 | 683 | var xhttp = new XMLHttpRequest(); 684 | xhttp.onloadend = function() { 685 | if(this.status === 200) { 686 | card.remove(); 687 | } 688 | else { 689 | card.style.display = null; 690 | viewCard(card.id); 691 | } 692 | } 693 | xhttp.open('GET', 'api.php?api=archiveCard&bid=' + boardID + '&listid=' + activeCard.list + '&cardid=' + activeCard.id + '&pid=' + pid, true); 694 | xhttp.send(); 695 | 696 | card.style.display = 'none'; 697 | route('home'); 698 | } 699 | 700 | 701 | const rgb2hex = (rgb) => '#' + rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/).slice(1).map((n) => parseFloat(n).toString(16).padStart(2,'0')).join(''); 702 | 703 | 704 | //Populate Tag Colors 705 | function populateTagColors() { 706 | for(color of tagPalette) { 707 | let colorTag = document.createElement('div'); 708 | colorTag.style.background = color; 709 | colorTag.setAttribute('onclick', "addTag('"+color+"')"); 710 | addTagBox.appendChild(colorTag); 711 | } 712 | } 713 | 714 | 715 | //Show Tags 716 | function showTags() { 717 | addTagBox.style.height = (addTagBox.style.height != '13px') ? '13px' : 0; 718 | } 719 | 720 | 721 | //Toggle Menu 722 | function toggleMenu(forced=false) { 723 | if(forced === 'close') document.body.classList.add('menu-closed'); 724 | else if(forced === 'open') document.body.classList.remove('menu-closed'); 725 | else document.body.classList.toggle('menu-closed'); 726 | if(document.body.classList.contains('menu-closed') && window.innerWidth > 1000) //don't set cookie on mobile. Disallows mobile menu being open by default 727 | setCookie('menuState', 'closed', false); 728 | else 729 | setCookie('menuState', '', true); 730 | } 731 | 732 | 733 | //Scroll Orientation 734 | function scrollOrientation() { 735 | main.classList.toggle('vertical'); 736 | var vertical = main.classList.contains('vertical'); 737 | orientationSvg.style.transform = vertical ? 'rotate(90deg)' : 'none'; 738 | setCookie('orientation', 'vertical', !vertical); 739 | } 740 | 741 | 742 | //Export Board 743 | function exportBoard() { 744 | if(boardID) 745 | window.location.href = 'api.php?api=export&bid=' + boardID; 746 | } 747 | 748 | 749 | //Import Board 750 | function importBoard() { 751 | if(importFile.files.length !== 1) return; 752 | importFile.disabled = true; 753 | loader.style.display = 'inline-block'; 754 | var file = importFile.files[0]; 755 | 756 | var xhttp = new XMLHttpRequest(); 757 | xhttp.onloadend = function() { 758 | importFile.disabled = false; 759 | loader.style.display = 'none'; 760 | importFile.value = null; 761 | if(this.status === 200) { 762 | route('home'); 763 | window.location = '?b=' + this.responseText; 764 | } 765 | else alert('Import Failed'); 766 | } 767 | xhttp.open('POST', 'api.php?api=import', true); 768 | xhttp.send(importFile.files[0]); 769 | } 770 | 771 | 772 | //DRAG DROP 773 | var offsetX, offsetY, draggedCard, dragster, spacer = document.createElement('div'); 774 | spacer.style.margin = '6px 0'; 775 | 776 | function dragStart(e) { 777 | draggedCard = e.target; 778 | dragster = draggedCard.cloneNode(true); 779 | dragster.classList.add('dragster'); 780 | document.body.appendChild(dragster); 781 | dragster.style.width = e.target.getBoundingClientRect().width + 'px'; 782 | spacer.style.height = e.target.getBoundingClientRect().height + 'px'; 783 | offsetX = e.pageX - e.target.getBoundingClientRect().x; 784 | offsetY = e.pageY - e.target.getBoundingClientRect().y; 785 | dragster.style.top = (e.pageY - offsetY - 6) + 'px'; 786 | dragster.style.left = (e.pageX - offsetX) + 'px'; 787 | draggedCard.insertAdjacentElement('beforebegin', spacer); 788 | draggedCard.style.display = 'none'; 789 | } 790 | function dragEnd(e) { 791 | spacer.remove(); 792 | if(dragster) { 793 | dragster.remove(); 794 | dragster = null; 795 | } 796 | draggedCard.style.display = null; 797 | } 798 | function dragEnter(e) { 799 | e.preventDefault(); 800 | e.target.insertAdjacentElement('beforebegin', spacer); 801 | } 802 | function dragOverNewCardBtn(e) { 803 | e.preventDefault(); 804 | e.target.previousElementSibling.insertAdjacentElement('beforeend', spacer); 805 | } 806 | function dragOver(e) { 807 | e.preventDefault(); 808 | dragster.style.top = (e.pageY - offsetY - 6) + 'px'; 809 | dragster.style.left = (e.pageX - offsetX) + 'px'; 810 | } 811 | 812 | 813 | //MOVE CARD 814 | function dragDrop(e) { 815 | e.preventDefault(); e.stopPropagation(); 816 | var lastInList = !spacer.nextElementSibling; 817 | var dropTarget = lastInList ? spacer.previousElementSibling : spacer.nextElementSibling; 818 | var dlid = spacer.parentElement.id.substr(2); 819 | var stid = draggedCard.id; 820 | var dtid = dropTarget ? dropTarget.id : '0'; 821 | var slist = draggedCard.parentElement; 822 | var voidTile = draggedCard.nextElementSibling; 823 | var vtid = voidTile ? voidTile.id : '0'; 824 | dragEnd(e); 825 | if(draggedCard === dropTarget) 826 | return; 827 | var spid = draggedCard.previousElementSibling ? draggedCard.previousElementSibling.id : '0'; 828 | var dpid = (dropTarget && dropTarget.previousElementSibling) ? dropTarget.previousElementSibling.id : '0'; 829 | if(!lastInList && dpid == stid) return; 830 | if(lastInList) { 831 | dpid = dtid; 832 | dtid = '0'; 833 | } 834 | //move card 835 | if(lastInList) { //moved to last in list 836 | if(dropTarget) //at least 1 card in list 837 | dropTarget.insertAdjacentElement('afterend', draggedCard); 838 | else //dest list is empty 839 | document.getElementById('cc' + dlid).appendChild(draggedCard); 840 | } 841 | else //move to anywhere but last/only 842 | dropTarget.insertAdjacentElement('beforebegin', draggedCard); 843 | 844 | var xhttp = new XMLHttpRequest(); 845 | xhttp.onloadend = function() { 846 | if(this.status !== 200) { //revert move 847 | if(voidTile) voidTile.insertAdjacentElement('beforebegin', draggedCard); 848 | else if(spid !== '0') document.getElementById(spid).insertAdjacentElement('afterend', draggedCard); 849 | else slist.appendChild(draggedCard); 850 | } 851 | } 852 | xhttp.open('GET', 'api.php?api=moveCard&bid=' + boardID + '&stid=' + stid + '&dtid=' + dtid + '&spid=' + spid + '&dpid=' + dpid + '&dlid=' + dlid + '&vtid=' + vtid, true); 853 | xhttp.send(); 854 | } 855 | 856 | -------------------------------------------------------------------------------- /setup-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | database=tellordb 4 | user=telloruser 5 | passwd=tellorpasswd 6 | host=localhost 7 | 8 | mysql -e "CREATE DATABASE ${database}" 9 | mysql $database -e "CREATE USER '${user}'@'${host}' IDENTIFIED BY '${passwd}'" 10 | mysql $database -e "GRANT ALL PRIVILEGES ON ${database}.* TO '${user}'@'${host}'" 11 | 12 | mysql $database -e "CREATE TABLE `boards`(`id` binary(16) NOT NULL, `name` varchar(127) NOT NULL, `bgimg` varchar(1023) CHARACTER SET ascii COLLATE ascii_bin, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" 13 | 14 | mysql $database -e "CREATE TABLE `lists`(`board` binary(16) NOT NULL, `id` binary(16) NOT NULL, `ordr` int(10) unsigned NOT NULL, `name` varchar(1023) NOT NULL, KEY `listsBoardIdx` (`board`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" 15 | 16 | mysql $database -e "CREATE TABLE `cards`(`board` binary(16) NOT NULL, `list` binary(16) NOT NULL, `id` binary(16) NOT NULL, `parent` char(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `title` varchar(1023) NOT NULL, `tags` varchar(127) CHARACTER SET ascii COLLATE ascii_bin, `color` char(7) CHARACTER SET ascii COLLATE ascii_bin, `cdate` datetime NOT NULL DEFAULT current_timestamp(), `mdate` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), `description` text, PRIMARY KEY (`board`,`list`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" 17 | 18 | mysql $database -e "CREATE TABLE `archive`(`board` binary(16) NOT NULL, `list` binary(16) NOT NULL, `id` binary(16) NOT NULL, `parent` char(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `title` varchar(1023) NOT NULL, `tags` varchar(127) CHARACTER SET ascii COLLATE ascii_bin, `color` char(7) CHARACTER SET ascii COLLATE ascii_bin, `cdate` datetime NOT NULL, `mdate` datetime NOT NULL, `description` text) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" 19 | 20 | mysql $database -e "CREATE TABLE `history`(`board` binary(16) NOT NULL, `cardid` binary(16) NOT NULL, `type` binary(3) NOT NULL, `change_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `patch` blob, INDEX historyIdx (`board`,`cardid`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" 21 | 22 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root {--main-bg:#383C43; --menu-bg:#282B2F; --menu-border:#000; --title:#A0ADBB; --text:#F0F0F0; --accent:#175CA2;} 2 | 3 | body {font-family:"Segoe UI",Tahoma,Arial,sans-serif;color:var(--text);background-color:var(--main-bg);font-size:14px;line-height:18px;text-align:left;margin:0;padding:0;} 4 | pre {font-family:"Segoe UI",Tahoma,Arial,sans-serif;} 5 | div,span,input,select,button,textarea {border:none;margin:0;padding:0;font-size:14px;} 6 | button,select {cursor:pointer;} 7 | a {color:var(--accent);text-decoration:none;} 8 | h1 {display:inline-block;font-size:28px;line-height:32px;font-weight:bold;margin:0;vertical-align:top;} 9 | h2 {display:block;font-size:16px;line-height:20px;font-weight:bold;margin:0;text-align:center;color:var(--text);padding:6px 8px;} 10 | 11 | /* HEAD */ 12 | .head {height:34px;color:var(--title);background-color:var(--menu-bg);border-bottom:1px solid var(--menu-border);} 13 | .menuBtn {background:none;padding:5px 10px;line-height:0;vertical-align:top;} 14 | .menuBtn:hover {transform:scale(1.25);} 15 | .menuBtn svg {height:24px;width:24px;stroke:var(--title);} 16 | .head select {margin:5px 10px 0 12px;color:var(--text);background-color:#202226;padding:4px 8px;border-left:2px solid #33f;border-right:2px solid #b33;border-radius:24px;vertical-align:top;} 17 | .head select:hover {border-color:var(--accent);} 18 | .orientationBtn {float:right;padding:0 8px;background:none;} 19 | .orientationBtn svg {display:block;stroke:var(--title);height:34px;width:34px;} 20 | .orientationBtn:hover svg {stroke:var(--accent);} 21 | 22 | /* MENU */ 23 | .menu {position:fixed;width:220px;padding-top:7px;overflow-y:auto;top:34px;bottom:0;left:0;word-break:break-all;text-align:center;z-index:1;background-color:var(--menu-bg);border-right:1px solid var(--menu-border);scrollbar-color:#0000 #0000 !important;transition:left ease .5s,scrollbar-color linear .5s;} 24 | .menu button {width:78%;color:#999;line-height:19px;padding:4px;background-color:#222426;margin:4px 0;border-radius:12px;border-left:2px solid black;border-right:2px solid black;} 25 | .menu button:hover {background-color:#1E2022;border-color:var(--accent);} 26 | .menu a {display:block;color:#A0A0A0;padding:3px 4px 5px 4px;margin:8px;border-radius:14px;border-top:1px solid #515151;border-bottom:1px solid #000;} 27 | .menu a:hover {background:#203040;} 28 | .menu a.activeBoard {color:var(--accent);background:none;border-left:2px solid var(--accent);border-right:2px solid var(--accent);} 29 | 30 | /* MAIN CARDS */ 31 | .main {display:flex;flex-flow:row nowrap;justify-content:start;align-items:start;position:absolute;top:35px;left:220px;right:0;bottom:0;padding:10px;background-size:cover;background-position:center;box-sizing:border-box;overflow-y:hidden;overflow-x:auto;scrollbar-width:thin;transition:left ease .5s;} 32 | .main.vertical {flex-wrap:wrap;row-gap:10px;overflow-y:auto;overflow-x:hidden;} 33 | .dragster {position:absolute;pointer-events:none;contain:content;} 34 | /* lists */ 35 | .list {display:flex;flex-flow:column nowrap;flex:0 0 284px;width:284px;max-height:100%;margin-right:10px;padding:5px 0;background-color:#101204;border-radius:9px;box-sizing:border-box;contain:content;} 36 | .listTitle {display:flex;flex-flow:row nowrap;justify-content:start;align-items:start;margin:0 8px 4px 8px;} 37 | .listTitle .listName {flex:1 1 50%;padding:0 0 2px 2px;margin-right:5px;border-radius:6px;white-space:pre-wrap;} 38 | .listTitle .listName:focus {outline:2px solid var(--accent);outline-offset:2px;} 39 | .moveListBtn, .deleteListBtn {color:#bbb;background:none;font-size:18px;line-height:18px;} 40 | .moveListBtn:hover {color:var(--accent);} 41 | .deleteListBtn {display:none;color:#911;margin-left:4px;} 42 | .listTitle .listName:focus ~ .moveListBtn {display:none;} 43 | .listTitle .listName:focus ~ .deleteListBtn, .listTitle .deleteListBtn:hover {display:block;} 44 | #newListName {margin:8px 0;padding:3px 5px;background:none;border:2px solid var(--accent);color:#ccc;border-radius:6px;} 45 | #newListBtn {padding:3px 7px;background:none;border:2px solid var(--accent);border-radius:9px;color:#ccc;font-size:12px;} 46 | #newListBtn:hover {background:#0002;} 47 | 48 | /* cards */ 49 | .cardContainer {padding:0 8px;min-height:12px;margin:-6px 0;overflow-y:auto;scrollbar-width:thin;} 50 | .card {background-color:#22272B;border-radius:7px;margin:6px 0;padding:0 8px 6px 8px;box-sizing:border-box;white-space:pre-wrap;cursor:pointer;} 51 | .addCardBtn {border-radius:7px;margin:4px 8px 0 8px;padding:1px 6px;cursor:pointer;} 52 | .card:hover, .addCardBtn:hover {background-color:#282e34;} 53 | .hasDescription {border-left:4px solid var(--accent);} 54 | 55 | /* POPUPS */ 56 | 57 | /* EDIT BOARD */ 58 | #popupBG {display:none;flex-flow:row nowrap;justify-content:center;align-items:center;position:fixed;inset:0;z-index:2;background-color:#80808060;content-visibility:auto;contain:strict;} 59 | #newBoardBox, #editBoardBox, #importExportBox {display:none;overflow:hidden;border-radius:13px;background:linear-gradient(150deg,var(--main-bg),var(--accent),var(--main-bg),var(--accent));} 60 | .popupBody {text-align:center;padding:6px 8px 0 8px;} 61 | .popupBody input[type="text"] {background:#80808024;padding:4px 10px;color:#FFF;border-top:1px solid #000;border-bottom:1px solid #fff5;border-radius:16px;} 62 | .popupBody input[type="text"]:focus {outline:none;background:none;} 63 | /* bottom buttons */ 64 | .bottomBtnsBar {display:flex;flex-flow:row nowrap;gap:2px;margin-top:12px;padding-top:1px;background-color:#000;} 65 | .bottomBtnsBar button {flex-grow:1;border:none;color:#000;padding:8px;text-align:center;} 66 | .bottomBtnsBar button:first-child {background-color:#8060ff;} 67 | .bottomBtnsBar button:last-child {background-color:#0BB;} 68 | .bottomBtnsBar button:hover {box-shadow: 3px 3px 8px -1px #000 inset;} 69 | .bottomBtnsBar button:first-child:hover {box-shadow:-3px 3px 8px -1px #000 inset;} 70 | #delBoardBtn {background-color:#911;} 71 | #delChk {accent-color:#911;} 72 | 73 | /* VIEW CARD DETAILS */ 74 | .viewCardBox {display:none;flex-flow:column nowrap;width:768px;max-width:90vw;max-height:97vh;padding:6px 12px;background-color:#282E33;border-radius:9px;} 75 | .cardTitle {font-size:16px;line-height:22px;margin:0 0 10px 0;padding:4px 4px 6px 4px;border-radius:7px;} 76 | .cardTitle:focus {background-color:#1a1e22;outline:1px solid var(--accent);} 77 | .cardDescription {display:none;font-family:monospace;line-height:16px;min-height:130px;width:100%;padding:4px;color:var(--text);background:#343a3f;overflow-y:auto;border:1px solid #21272c;overflow-wrap:break-word;box-sizing:border-box;white-space:pre-wrap;} 78 | .dates {float:right;color:#888;font-size:12px;} 79 | #cdate, #mdate {font-size:inherit;} 80 | .saveDescriptionBtns {display:none;} 81 | #cardDescTA:focus {outline:2px solid var(--accent);} 82 | #cardDescTA:focus + div .saveDescriptionBtns, .saveDescriptionBtns:hover {display:inline-block;} 83 | .saveDescriptionBtns {margin-top:6px;} 84 | #sav {color:var(--accent);font-size:16px;line-height:18px;padding:5px 10px;margin-right:8px;border:2px solid var(--accent);border-radius:7px;background:none;} 85 | #sav:hover {color:#000;background:var(--accent);} 86 | #can {color:var(--title);font-size:16px;line-height:18px;padding:7px 10px;border-radius:7px;background:none;} 87 | #can:hover {background:#383c40;} 88 | #archiveBtn {float:right;padding:3px 8px;margin:10px 0 2px 8px;background:none;border:2px solid #911;color:#911;border-radius:7px;} 89 | #archiveBtn:hover {background:#911;color:#000;border-color:#000;} 90 | .cardDescription a:hover {text-decoration:underline;color:#2369B0;} 91 | 92 | /* TAGS */ 93 | .tags {padding:2px 4px;line-height:0;pointer-events:none;} 94 | .tags div {display:inline-block;width:60px;height:5px;margin:2px 0 1px 0;transform:skewX(135deg);} 95 | #tagsBox {margin-top:2px;} 96 | #tagsBox div, #tagsBox button {display:inline-block;position:relative;height:13px;width:48px;line-height:0;vertical-align:top;cursor:pointer;transform:skewX(135deg);} 97 | #addTagBox {display:flex;flex-flow:row nowrap;height:0;margin-bottom:12px;} 98 | #addTagBox div {flex:1 1 7.69%;cursor:pointer;transform:skewX(135deg);} 99 | #cardTextColorPicker {float:right;height:13px;width:32px;cursor:pointer;transform:skewX(45deg);} 100 | #cardTextColorPicker::-webkit-color-swatch-wrapper {padding:0;} 101 | #cardTextColorPicker::-webkit-color-swatch {border:none;} 102 | #cardTextColorPicker::-moz-color-swatch {border:none;} 103 | #tagsBox div:hover, #tagsBox button:hover, #addTagBox div:hover, #cardTextColorPicker:hover {z-index:1;scale:1.25;} 104 | 105 | /* MISC */ 106 | #loader {display:none;width:40px;height:40px;border-radius:50%;border-top:3px solid #FFF;border-right:3px solid transparent;animation:rotation 1s linear infinite;} 107 | @keyframes rotation {0% {transform:rotate(0deg);} 100% {transform:rotate(360deg);}} 108 | 109 | body.menu-closed .menu {left:-222px;} 110 | body.menu-closed .main {left:0;} 111 | @media (max-width:1000px) { 112 | .menu {left:-222px;} 113 | .main, body.menu-closed .menu {left:0;} 114 | } 115 | 116 | ::selection {color:var(--accent);background-color:#000;} 117 | :root {scrollbar-width:thin !important;scrollbar-color:#338 #0000 !important;} 118 | 119 | --------------------------------------------------------------------------------