├── src ├── declaration.d.ts ├── dbEditor.scss ├── dbEditor.html └── dbEditor.ts ├── tsconfig.json ├── package.json ├── README.md └── .gitignore /src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noImplicitAny": true, 8 | "module": "es6", 9 | "moduleResolution": "node", 10 | "target": "es5", 11 | "lib": ["es2017", "dom"], 12 | "allowSyntheticDefaultImports": true, 13 | "typeRoots": ["./node_modules/@types", "./src/@types"], 14 | "downlevelIteration": true, 15 | "resolveJsonModule": true, 16 | "declaration": true 17 | }, 18 | "include": [ 19 | "./src/**/*" 20 | ] 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-editor", 3 | "version": "0.1.0", 4 | "description": "Database viewer / editor for various data types", 5 | "main": "dist/db-editor.js", 6 | "types": "dist/db-editor.d.ts", 7 | "author": { 8 | "name": "Pacharapol Withayasakpunt", 9 | "url": "http://patarapolw.github.io", 10 | "email": "patarapolw@gmail.com" 11 | }, 12 | "license": "MIT", 13 | "dependencies": { 14 | "bootstrap": "^4.3.1", 15 | "flatpickr": "^4.5.7", 16 | "jquery": "^3.3.1", 17 | "popper.js": "^1.14.7", 18 | "simplemde": "^1.11.2", 19 | "tingle.js": "^0.14.0" 20 | }, 21 | "devDependencies": { 22 | "@types/jquery": "^3.3.29", 23 | "@types/simplemde": "^1.11.7", 24 | "@types/tingle.js": "^0.13.0", 25 | "node-sass": "^4.11.0" 26 | }, 27 | "scripts": { 28 | "build": "tsc", 29 | "build-css": "node-sass src/ -o dist/" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DB Editor 2 | 3 | Database editor without HandsOnTable 4 | 5 | ## Example usage 6 | 7 | ```typescript 8 | const editor = new DbEditor({ 9 | el: document.getElementById("App")!, 10 | endpoint: "/api/editor/", 11 | convert: md2html, 12 | columns: [ 13 | {name: "deck", width: 200, type: "one-line", required: true}, 14 | {name: "front", width: 500, type: "markdown", required: true}, 15 | {name: "back", width: 500, type: "markdown"}, 16 | {name: "tag", width: 150, type: "list", newEntry: false}, 17 | {name: "mnemonic", width: 300, type: "markdown", newEntry: false}, 18 | {name: "srsLevel", width: 150, type: "number", label: "SRS Level", newEntry: false}, 19 | {name: "nextReview", width: 200, type: "datetime", label: "Next Review", newEntry: false} 20 | ] 21 | }); 22 | ``` 23 | 24 | ```typescript 25 | const editor = new DbEditor({ 26 | el: document.getElementById("App")!, 27 | endpoint: "/api/editor/", 28 | convert: md2html, 29 | readOnly: true, 30 | newEntry: false, 31 | columns: [ 32 | {name: "deck", width: 200, type: "one-line", required: true}, 33 | {name: "front", width: 600, type: "markdown", required: true}, 34 | {name: "back", width: 600, type: "markdown"}, 35 | {name: "tag", width: 150, type: "list", newEntry: false} 36 | ] 37 | }); 38 | ``` 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | *.lock 3 | .idea/ 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | lib-cov 16 | coverage 17 | .nyc_output 18 | .grunt 19 | bower_components 20 | .lock-wscript 21 | build/Release 22 | node_modules/ 23 | jspm_packages/ 24 | typings/ 25 | .npm 26 | .eslintcache 27 | .node_repl_history 28 | *.tgz 29 | .yarn-integrity 30 | .env 31 | .env.test 32 | .cache 33 | .next 34 | .nuxt 35 | .vuepress/dist 36 | .serverless/ 37 | .fusebox/ 38 | .dynamodb/ 39 | __pycache__/ 40 | *.py[cod] 41 | *$py.class 42 | *.so 43 | .Python 44 | build/ 45 | develop-eggs/ 46 | dist/ 47 | downloads/ 48 | eggs/ 49 | .eggs/ 50 | lib/ 51 | lib64/ 52 | parts/ 53 | sdist/ 54 | var/ 55 | wheels/ 56 | pip-wheel-metadata/ 57 | share/python-wheels/ 58 | *.egg-info/ 59 | .installed.cfg 60 | *.egg 61 | MANIFEST 62 | *.manifest 63 | *.spec 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | htmlcov/ 67 | .tox/ 68 | .nox/ 69 | .coverage 70 | .coverage.* 71 | nosetests.xml 72 | coverage.xml 73 | *.cover 74 | .hypothesis/ 75 | .pytest_cache/ 76 | *.mo 77 | *.pot 78 | local_settings.py 79 | db.sqlite3 80 | instance/ 81 | .webassets-cache 82 | .scrapy 83 | docs/_build/ 84 | target/ 85 | .ipynb_checkpoints 86 | profile_default/ 87 | ipython_config.py 88 | .python-version 89 | celerybeat-schedule 90 | *.sage.py 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | .spyderproject 98 | .spyproject 99 | .ropeproject 100 | /site 101 | .mypy_cache/ 102 | .dmypy.json 103 | dmypy.json 104 | .pyre/ 105 | -------------------------------------------------------------------------------- /src/dbEditor.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap.scss"; 2 | @import "~tingle.js/src/tingle"; 3 | @import "~simplemde/dist/simplemde.min"; 4 | @import "~flatpickr/dist/flatpickr"; 5 | 6 | html, body { 7 | height: 100%; 8 | overflow: hidden; 9 | } 10 | 11 | .db-editor { 12 | height: 100%; 13 | overflow: hidden; 14 | 15 | .navbar { 16 | height: 60px; 17 | } 18 | 19 | nav { 20 | z-index: 999 !important; 21 | } 22 | 23 | .db-editor-table { 24 | height: calc(100% - 60px); 25 | overflow: scroll; 26 | 27 | thead, thead th { 28 | position: sticky; 29 | top: 0; 30 | z-index: 100; 31 | background-color: white; 32 | } 33 | 34 | td .cell-wrapper { 35 | max-width: 500px; 36 | max-height: 150px !important; 37 | overflow-y: scroll; 38 | } 39 | } 40 | 41 | .navbar-collapse { 42 | background: #f8f9fa; 43 | border: 1px gray !important; 44 | } 45 | } 46 | 47 | 48 | textarea { 49 | resize: none; 50 | } 51 | 52 | pre { 53 | white-space: pre-wrap; 54 | } 55 | 56 | .CodeMirror, 57 | .CodeMirror-fullscreen.CodeMirror, 58 | .editor-preview-side { 59 | max-height: 80% !important; 60 | height: 80% !important; 61 | } 62 | 63 | .db-editor-md-editor { 64 | height: 500px; 65 | overflow: scroll; 66 | } 67 | 68 | form .CodeMirror { 69 | min-height: 100px !important; 70 | height: 100px !important; 71 | } 72 | 73 | .clear { 74 | background: rgba(0, 0, 0, 0); 75 | border: 0px; 76 | } 77 | 78 | .db-editor-new-entry-editor textarea { 79 | height: 150px; 80 | overflow: scroll; 81 | } 82 | 83 | img { 84 | max-width: 500px; 85 | } -------------------------------------------------------------------------------- /src/dbEditor.html: -------------------------------------------------------------------------------- 1 | 43 |
44 | 45 | 46 | 47 | 48 | 49 |
50 |
-------------------------------------------------------------------------------- /src/dbEditor.ts: -------------------------------------------------------------------------------- 1 | import $ from "jquery"; 2 | import tingle from "tingle.js"; 3 | import SimpleMDE from "simplemde"; 4 | import "bootstrap"; 5 | import flatpickr from "flatpickr"; 6 | import dbEditorHtml from "./dbEditor.html"; 7 | import "./dbEditor.scss"; 8 | 9 | export interface IColumn { 10 | name: string; 11 | width: number; 12 | readOnly?: boolean; 13 | label?: string; 14 | type?: "one-line" | "multi-line" | "markdown" | "number" | "datetime" | "list"; 15 | newEntry?: boolean; 16 | required?: boolean; 17 | requiredText?: string; 18 | convert?: (x: any) => string; 19 | parse?: (x: string) => any; 20 | constraint?: (x: any) => boolean; 21 | } 22 | 23 | export interface IDbEditorSettings { 24 | el: JQuery | HTMLElement; 25 | columns: IColumn[]; 26 | endpoint: string; 27 | readOnly?: boolean; 28 | newEntry?: boolean; 29 | convert?: (x: any) => string; 30 | } 31 | 32 | interface IJqList { 33 | [key: string]: JQuery; 34 | } 35 | 36 | interface IMdeList { 37 | [key: string]: SimpleMDE; 38 | } 39 | 40 | interface IModalList { 41 | [key: string]: tingle.modal; 42 | } 43 | 44 | export class DbEditor { 45 | private settings: IDbEditorSettings; 46 | private page = { 47 | current: 0, 48 | count: 0, 49 | from: 0, 50 | to: 0, 51 | total: 0 52 | }; 53 | 54 | private $el: IJqList = {}; 55 | private mde: IMdeList = {}; 56 | private modal: IModalList = {}; 57 | 58 | constructor(settings: IDbEditorSettings) { 59 | this.settings = settings; 60 | this.$el.main = $(settings.el); 61 | this.$el.main.addClass("db-editor"); 62 | this.$el.main.html(dbEditorHtml); 63 | 64 | this.$el.tbody = $("tbody", settings.el); 65 | this.$el.newEntryButton = $(".db-editor-new-entry-button", settings.el); 66 | this.$el.searchBar = $(".db-editor-search-bar", settings.el); 67 | this.$el.prev = $(".db-editor-prev", settings.el); 68 | this.$el.prevAll = $(".db-editor-prevAll", settings.el); 69 | this.$el.next = $(".db-editor-next", settings.el); 70 | this.$el.nextAll = $(".db-editor-next-all", settings.el); 71 | this.$el.numberCurrent = $(".db-editor-number-current", settings.el); 72 | this.$el.numberTotal = $(".db-editor-number-total", settings.el); 73 | 74 | if (typeof settings.newEntry === "boolean" && !settings.newEntry) { 75 | $(".db-editor-new-entry-button-nav", settings.el).hide(); 76 | } else { 77 | this.$el.newEntry = $(` 78 |
79 |

Add new entry

80 |
`); 81 | 82 | for (const col of this.settings.columns) { 83 | if (typeof col.newEntry === "boolean" && !col.newEntry) { 84 | continue; 85 | } 86 | 87 | switch (col.type) { 88 | case "one-line": 89 | case "number": 90 | case "list": 91 | case "datetime": 92 | this.$el[col.name] = $(` 93 |
94 | 95 |
96 | 98 |
99 |
`); 100 | break; 101 | case "markdown": 102 | default: 103 | this.$el[col.name] = $(` 104 |
105 | 106 | 108 |
"`); 109 | } 110 | 111 | this.$el.newEntry.append(this.$el[col.name]); 112 | this.$el[col.name].data("col", col); 113 | 114 | if (col.required) { 115 | $("input, textarea", this.$el[col.name]).parent().append(` 116 |
117 | ${col.requiredText ? col.requiredText : `${toTitle(col.name)} is required.`} 118 |
`); 119 | } 120 | } 121 | 122 | this.modal.newEntry = new tingle.modal({ 123 | footer: true, 124 | stickyFooter: false, 125 | onClose: () => { 126 | (this.$el.newEntry.get(0) as HTMLFormElement).reset(); 127 | Object.values(this.mde).forEach((el) => el.value("")); 128 | } 129 | }); 130 | 131 | this.$el.main.append(this.$el.newEntry); 132 | this.modal.newEntry.setContent(this.$el.newEntry.get(0)); 133 | this.modal.newEntry.addFooterBtn("Save", "tingle-btn tingle-btn--primary", () => { 134 | for (const col of this.settings.columns) { 135 | if (col.type === "markdown") { 136 | this.$el[col.name].val(this.mde[col.name].value()); 137 | } 138 | } 139 | 140 | for (const col of this.settings.columns) { 141 | if (col.required) { 142 | col.constraint = col.constraint ? col.constraint : (x: any) => !!x; 143 | if (!col.constraint!(this.$el[col.name].val())) { 144 | this.$el.newEntry.addClass("was-validated"); 145 | return; 146 | } 147 | } 148 | } 149 | 150 | const entry = {} as any; 151 | for (const col of this.settings.columns) { 152 | entry[col.name] = this.$el[col.name].val(); 153 | } 154 | 155 | this.addEntry(entry, true); 156 | this.modal.newEntry.close(); 157 | }); 158 | } 159 | 160 | if (!settings.readOnly) { 161 | this.$el.mdEditor = $(` 162 |
163 | 164 |
`); 165 | this.$el.main.append(this.$el.mdEditor); 166 | 167 | this.$el.listEditor = $(` 168 |
169 |

170 |
171 |
172 | 173 |
`); 174 | this.$el.main.append(this.$el.listEditor); 175 | 176 | this.mde.mdEditor = new SimpleMDE({ 177 | element: $("textarea", this.$el.mdEditor).get(0), 178 | spellChecker: false, 179 | previewRender: settings.convert 180 | }); 181 | 182 | for (const col of this.settings.columns) { 183 | if (col.type === "markdown") { 184 | if (typeof col.newEntry === "boolean" && !col.newEntry) { 185 | continue; 186 | } 187 | 188 | col.convert = col.convert ? col.convert : this.settings.convert; 189 | 190 | this.mde[col.name] = new SimpleMDE({ 191 | element: $("textarea", this.$el[col.name]).get(0), 192 | spellChecker: false, 193 | previewRender: col.convert 194 | }); 195 | } 196 | } 197 | 198 | this.modal.mdEditor = new tingle.modal({ 199 | footer: true, 200 | stickyFooter: false, 201 | onClose: () => this.mde.mdEditor.value("") 202 | }); 203 | this.modal.mdEditor.setContent(this.$el.mdEditor.get(0)); 204 | this.modal.mdEditor.addFooterBtn("Save", "tingle-btn tingle-btn--primary", () => { 205 | const val = this.mde.mdEditor.value(); 206 | const $target = this.$el.mdEditor.data("$target"); 207 | const convertFn = $target.data("col").convert; 208 | 209 | this.updateServer($target, val) 210 | .then(() => convertFn ? $target.html(convertFn(val)) : $target.text(val)); 211 | 212 | this.modal.mdEditor.close(); 213 | }); 214 | 215 | this.modal.listEditor = new tingle.modal({ 216 | footer: true, 217 | stickyFooter: false, 218 | onClose: () => $(".db-editor-list-entry").remove() 219 | }); 220 | this.modal.listEditor.setContent(this.$el.listEditor.get(0)); 221 | this.modal.listEditor.addFooterBtn("Save", "tingle-btn tingle-btn--primary", () => { 222 | const ls = $(".db-editor-list-entry input").toArray().map((el) => $(el).val()).filter((el) => el).sort(); 223 | const $target = this.$el.mdEditor.data("$target"); 224 | 225 | this.updateServer($target, ls) 226 | .then(() => $target.text(ls.join("\n"))); 227 | 228 | this.modal.listEditor.close(); 229 | }); 230 | } 231 | 232 | this.$el.prevAll.on("click", () => { 233 | this.page.current = 1; 234 | this.fetchData(); 235 | }); 236 | 237 | this.$el.prev.on("click", () => { 238 | this.page.current--; 239 | this.fetchData(); 240 | }); 241 | 242 | this.$el.next.on("click", () => { 243 | this.page.current++; 244 | this.fetchData(); 245 | }); 246 | 247 | this.$el.nextAll.on("click", () => { 248 | this.page.current = this.page.count; 249 | this.fetchData(); 250 | }); 251 | 252 | this.$el.newEntryButton.on("click", () => { 253 | this.modal.newEntry.open(); 254 | }); 255 | 256 | if (!settings.readOnly) { 257 | this.$el.tbody.on("click", "td", (e) => { 258 | const $target = $(e.target).closest("td"); 259 | const fieldName: string = $target.data("name"); 260 | const fieldData = $target.data("data"); 261 | const col: IColumn = $target.data("col"); 262 | 263 | if (col.type === "datetime") { 264 | $target.find("input").get(0)._flatpickr.open(); 265 | return; 266 | } 267 | 268 | if (col.type === "list") { 269 | if (fieldData) { 270 | fieldData.forEach((el: string) => this.addListEntry(el)); 271 | } 272 | this.addListEntry(); 273 | 274 | $(".db-entry-list-entry input", settings.el).each((i, el) => this.checkListInput(el as HTMLInputElement)); 275 | this.$el.listEditor.data("$target", $target); 276 | 277 | this.modal.listEditor.open(); 278 | return; 279 | } 280 | 281 | if (col.type === "markdown") { 282 | this.mde.mdEditor.value(fieldData); 283 | this.$el.mdEditor.data("$target", $target); 284 | this.modal.mdEditor.open(); 285 | setTimeout(() => this.mde.mdEditor.codemirror.refresh(), 0); 286 | return; 287 | } 288 | 289 | const data = { 290 | offset: $target.offset(), 291 | height: e.target.clientHeight, 292 | width: e.target.clientWidth, 293 | fieldName, 294 | fieldData 295 | }; 296 | 297 | const $input = $("