├── 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 |
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 = `+ 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 = `+ 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 |
--------------------------------------------------------------------------------