├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── Makefile ├── README.md ├── components └── todo.go ├── index.html ├── index.js ├── index.wasm ├── main.go ├── store ├── helpers.go ├── mutations.go ├── state.go └── todo.go └── style ├── style.go └── todo.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .idea -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | digest = "1:ebb1430594f103e9ab0171c02f4e9e29ef63dcc731081400e3194b2dfe921b0e" 7 | name = "github.com/syumai/go-hyperscript" 8 | packages = [ 9 | "dom", 10 | "hyperscript", 11 | ] 12 | pruneopts = "UT" 13 | revision = "d8fd65fa414d44b807cc85ce651269dfecaa1a6a" 14 | 15 | [solve-meta] 16 | analyzer-name = "dep" 17 | analyzer-version = 1 18 | input-imports = [ 19 | "github.com/syumai/go-hyperscript/dom", 20 | "github.com/syumai/go-hyperscript/hyperscript", 21 | ] 22 | solver-name = "gps-cdcl" 23 | solver-version = 1 24 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [prune] 29 | go-tests = true 30 | unused-packages = true 31 | 32 | [[constraint]] 33 | branch = "master" 34 | name = "github.com/syumai/go-hyperscript" 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | GOOS=js GOARCH=wasm go build -o ./index.wasm . 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-wasm-todo-list 2 | 3 | * Pure Go ToDo List app running on the browser using the power of WebAssembly. 4 | * This app is implemented in [go-hyperscript](https://github.com/syumai/go-hyperscript/). 5 | 6 | ## Run App 7 | 8 | * https://syumai.github.io/go-wasm-todo-list/ 9 | 10 | ## Environment 11 | 12 | * Go 1.12 13 | 14 | ## Development 15 | 16 | ```console 17 | make build 18 | ``` 19 | 20 | ## Author 21 | 22 | syumai 23 | 24 | ## License 25 | 26 | MIT 27 | -------------------------------------------------------------------------------- /components/todo.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "strconv" 5 | 6 | h "github.com/syumai/go-hyperscript/hyperscript" 7 | "github.com/syumai/go-wasm-todo-list/store" 8 | "github.com/syumai/go-wasm-todo-list/style" 9 | ) 10 | 11 | func toDoItem(props h.Object) h.VNode { 12 | updateToDo := props.Get("updateToDo").(func(int, bool)) 13 | toDo := props.Get("toDo").(*store.ToDo) 14 | return h.H("li", nil, 15 | h.H("input", h.Object{ 16 | "type": "checkbox", 17 | "checked": toDo.Done, 18 | "onchange": h.FuncOf(func(this h.Value, args []h.Value) interface{} { 19 | updateToDo(toDo.ID, !toDo.Done) 20 | return nil 21 | }), 22 | }), 23 | h.Text(toDo.Title), 24 | ) 25 | } 26 | 27 | func toDoList(props h.Object) h.VNode { 28 | toDos := props.Get("toDos").([]*store.ToDo) 29 | elements := make(h.VNodes, len(toDos)) 30 | for i, toDo := range toDos { 31 | elements[i] = toDoItem(h.Object{ 32 | "toDo": toDo, 33 | "updateToDo": props.Get("updateToDo"), 34 | }) 35 | } 36 | return h.H("ul", nil, elements...) 37 | } 38 | 39 | func ToDo(props h.Object) h.VNode { 40 | toDos := props.Get("toDos").([]*store.ToDo) 41 | doingToDos, doneToDos := store.SeparateToDos(toDos) 42 | return h.H("div", nil, 43 | h.H("div", h.Object{"className": "input"}, 44 | h.H("form", h.Object{ 45 | "autocomplete": "off", 46 | "onsubmit": props.Func("appendToDo"), 47 | }, 48 | h.H("input", h.Object{ 49 | "type": "text", 50 | "name": "title", 51 | "placeholder": "Input title", 52 | "value": props.String("title"), 53 | "oninput": props.Func("setTitle"), 54 | }), 55 | h.H("button", nil, h.Text("Add")), 56 | ), 57 | ), 58 | h.H("div", h.Object{"style": style.ListContainerStyle}, 59 | h.H("div", h.Object{"style": style.ListStyle}, 60 | h.H("h3", nil, h.Text("Doing")), 61 | h.H("div", nil, h.Text("Count: "+strconv.Itoa(len(doingToDos)))), 62 | h.H(toDoList, h.Object{ 63 | "updateToDo": props.Get("updateToDo"), 64 | "toDos": doingToDos, 65 | }), 66 | ), 67 | h.H("div", h.Object{"style": style.ListStyle}, 68 | h.H("h3", nil, h.Text("Done")), 69 | h.H("div", nil, h.Text("Count: "+strconv.Itoa(len(doneToDos)))), 70 | h.H(toDoList, h.Object{ 71 | "updateToDo": props.Get("updateToDo"), 72 | "toDos": doneToDos, 73 | }), 74 | ), 75 | ), 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | go-wasm-todo-list 7 | 8 | 9 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | (() => { 6 | window.global = window; 7 | 8 | // Map web browser API and Node.js API to a single common API (preferring web standards over Node.js API). 9 | let outputBuf = ""; 10 | global.fs = { 11 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused 12 | writeSync(fd, buf) { 13 | outputBuf += decoder.decode(buf); 14 | const nl = outputBuf.lastIndexOf("\n"); 15 | if (nl != -1) { 16 | console.log(outputBuf.substr(0, nl)); 17 | outputBuf = outputBuf.substr(nl + 1); 18 | } 19 | return buf.length; 20 | }, 21 | write(fd, buf, offset, length, position, callback) { 22 | if (offset !== 0 || length !== buf.length || position !== null) { 23 | throw new Error("not implemented"); 24 | } 25 | const n = this.writeSync(fd, buf); 26 | callback(null, n); 27 | }, 28 | open(path, flags, mode, callback) { 29 | const err = new Error("not implemented"); 30 | err.code = "ENOSYS"; 31 | callback(err); 32 | }, 33 | read(fd, buffer, offset, length, position, callback) { 34 | const err = new Error("not implemented"); 35 | err.code = "ENOSYS"; 36 | callback(err); 37 | }, 38 | fsync(fd, callback) { 39 | callback(null); 40 | }, 41 | }; 42 | 43 | const encoder = new TextEncoder("utf-8"); 44 | const decoder = new TextDecoder("utf-8"); 45 | 46 | global.Go = class { 47 | constructor() { 48 | this.argv = ["js"]; 49 | this.env = {}; 50 | this.exit = (code) => { 51 | if (code !== 0) { 52 | console.warn("exit code:", code); 53 | } 54 | }; 55 | this._exitPromise = new Promise((resolve) => { 56 | this._resolveExitPromise = resolve; 57 | }); 58 | this._pendingEvent = null; 59 | this._scheduledTimeouts = new Map(); 60 | this._nextCallbackTimeoutID = 1; 61 | 62 | const mem = () => { 63 | // The buffer may change when requesting more memory. 64 | return new DataView(this._inst.exports.mem.buffer); 65 | } 66 | 67 | const setInt64 = (addr, v) => { 68 | mem().setUint32(addr + 0, v, true); 69 | mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); 70 | } 71 | 72 | const getInt64 = (addr) => { 73 | const low = mem().getUint32(addr + 0, true); 74 | const high = mem().getInt32(addr + 4, true); 75 | return low + high * 4294967296; 76 | } 77 | 78 | const loadValue = (addr) => { 79 | const f = mem().getFloat64(addr, true); 80 | if (f === 0) { 81 | return undefined; 82 | } 83 | if (!isNaN(f)) { 84 | return f; 85 | } 86 | 87 | const id = mem().getUint32(addr, true); 88 | return this._values[id]; 89 | } 90 | 91 | const storeValue = (addr, v) => { 92 | const nanHead = 0x7FF80000; 93 | 94 | if (typeof v === "number") { 95 | if (isNaN(v)) { 96 | mem().setUint32(addr + 4, nanHead, true); 97 | mem().setUint32(addr, 0, true); 98 | return; 99 | } 100 | if (v === 0) { 101 | mem().setUint32(addr + 4, nanHead, true); 102 | mem().setUint32(addr, 1, true); 103 | return; 104 | } 105 | mem().setFloat64(addr, v, true); 106 | return; 107 | } 108 | 109 | switch (v) { 110 | case undefined: 111 | mem().setFloat64(addr, 0, true); 112 | return; 113 | case null: 114 | mem().setUint32(addr + 4, nanHead, true); 115 | mem().setUint32(addr, 2, true); 116 | return; 117 | case true: 118 | mem().setUint32(addr + 4, nanHead, true); 119 | mem().setUint32(addr, 3, true); 120 | return; 121 | case false: 122 | mem().setUint32(addr + 4, nanHead, true); 123 | mem().setUint32(addr, 4, true); 124 | return; 125 | } 126 | 127 | let ref = this._refs.get(v); 128 | if (ref === undefined) { 129 | ref = this._values.length; 130 | this._values.push(v); 131 | this._refs.set(v, ref); 132 | } 133 | let typeFlag = 0; 134 | switch (typeof v) { 135 | case "string": 136 | typeFlag = 1; 137 | break; 138 | case "symbol": 139 | typeFlag = 2; 140 | break; 141 | case "function": 142 | typeFlag = 3; 143 | break; 144 | } 145 | mem().setUint32(addr + 4, nanHead | typeFlag, true); 146 | mem().setUint32(addr, ref, true); 147 | } 148 | 149 | const loadSlice = (addr) => { 150 | const array = getInt64(addr + 0); 151 | const len = getInt64(addr + 8); 152 | return new Uint8Array(this._inst.exports.mem.buffer, array, len); 153 | } 154 | 155 | const loadSliceOfValues = (addr) => { 156 | const array = getInt64(addr + 0); 157 | const len = getInt64(addr + 8); 158 | const a = new Array(len); 159 | for (let i = 0; i < len; i++) { 160 | a[i] = loadValue(array + i * 8); 161 | } 162 | return a; 163 | } 164 | 165 | const loadString = (addr) => { 166 | const saddr = getInt64(addr + 0); 167 | const len = getInt64(addr + 8); 168 | return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); 169 | } 170 | 171 | const timeOrigin = Date.now() - performance.now(); 172 | this.importObject = { 173 | go: { 174 | // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) 175 | // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported 176 | // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). 177 | // This changes the SP, thus we have to update the SP used by the imported function. 178 | 179 | // func wasmExit(code int32) 180 | "runtime.wasmExit": (sp) => { 181 | const code = mem().getInt32(sp + 8, true); 182 | this.exited = true; 183 | delete this._inst; 184 | delete this._values; 185 | delete this._refs; 186 | this.exit(code); 187 | }, 188 | 189 | // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) 190 | "runtime.wasmWrite": (sp) => { 191 | const fd = getInt64(sp + 8); 192 | const p = getInt64(sp + 16); 193 | const n = mem().getInt32(sp + 24, true); 194 | fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); 195 | }, 196 | 197 | // func nanotime() int64 198 | "runtime.nanotime": (sp) => { 199 | setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); 200 | }, 201 | 202 | // func walltime() (sec int64, nsec int32) 203 | "runtime.walltime": (sp) => { 204 | const msec = (new Date).getTime(); 205 | setInt64(sp + 8, msec / 1000); 206 | mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); 207 | }, 208 | 209 | // func scheduleTimeoutEvent(delay int64) int32 210 | "runtime.scheduleTimeoutEvent": (sp) => { 211 | const id = this._nextCallbackTimeoutID; 212 | this._nextCallbackTimeoutID++; 213 | this._scheduledTimeouts.set(id, setTimeout( 214 | () => { this._resume(); }, 215 | getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early 216 | )); 217 | mem().setInt32(sp + 16, id, true); 218 | }, 219 | 220 | // func clearTimeoutEvent(id int32) 221 | "runtime.clearTimeoutEvent": (sp) => { 222 | const id = mem().getInt32(sp + 8, true); 223 | clearTimeout(this._scheduledTimeouts.get(id)); 224 | this._scheduledTimeouts.delete(id); 225 | }, 226 | 227 | // func getRandomData(r []byte) 228 | "runtime.getRandomData": (sp) => { 229 | crypto.getRandomValues(loadSlice(sp + 8)); 230 | }, 231 | 232 | // func stringVal(value string) ref 233 | "syscall/js.stringVal": (sp) => { 234 | storeValue(sp + 24, loadString(sp + 8)); 235 | }, 236 | 237 | // func valueGet(v ref, p string) ref 238 | "syscall/js.valueGet": (sp) => { 239 | const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); 240 | sp = this._inst.exports.getsp(); // see comment above 241 | storeValue(sp + 32, result); 242 | }, 243 | 244 | // func valueSet(v ref, p string, x ref) 245 | "syscall/js.valueSet": (sp) => { 246 | Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); 247 | }, 248 | 249 | // func valueIndex(v ref, i int) ref 250 | "syscall/js.valueIndex": (sp) => { 251 | storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); 252 | }, 253 | 254 | // valueSetIndex(v ref, i int, x ref) 255 | "syscall/js.valueSetIndex": (sp) => { 256 | Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); 257 | }, 258 | 259 | // func valueCall(v ref, m string, args []ref) (ref, bool) 260 | "syscall/js.valueCall": (sp) => { 261 | try { 262 | const v = loadValue(sp + 8); 263 | const m = Reflect.get(v, loadString(sp + 16)); 264 | const args = loadSliceOfValues(sp + 32); 265 | const result = Reflect.apply(m, v, args); 266 | sp = this._inst.exports.getsp(); // see comment above 267 | storeValue(sp + 56, result); 268 | mem().setUint8(sp + 64, 1); 269 | } catch (err) { 270 | storeValue(sp + 56, err); 271 | mem().setUint8(sp + 64, 0); 272 | } 273 | }, 274 | 275 | // func valueInvoke(v ref, args []ref) (ref, bool) 276 | "syscall/js.valueInvoke": (sp) => { 277 | try { 278 | const v = loadValue(sp + 8); 279 | const args = loadSliceOfValues(sp + 16); 280 | const result = Reflect.apply(v, undefined, args); 281 | sp = this._inst.exports.getsp(); // see comment above 282 | storeValue(sp + 40, result); 283 | mem().setUint8(sp + 48, 1); 284 | } catch (err) { 285 | storeValue(sp + 40, err); 286 | mem().setUint8(sp + 48, 0); 287 | } 288 | }, 289 | 290 | // func valueNew(v ref, args []ref) (ref, bool) 291 | "syscall/js.valueNew": (sp) => { 292 | try { 293 | const v = loadValue(sp + 8); 294 | const args = loadSliceOfValues(sp + 16); 295 | const result = Reflect.construct(v, args); 296 | sp = this._inst.exports.getsp(); // see comment above 297 | storeValue(sp + 40, result); 298 | mem().setUint8(sp + 48, 1); 299 | } catch (err) { 300 | storeValue(sp + 40, err); 301 | mem().setUint8(sp + 48, 0); 302 | } 303 | }, 304 | 305 | // func valueLength(v ref) int 306 | "syscall/js.valueLength": (sp) => { 307 | setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); 308 | }, 309 | 310 | // valuePrepareString(v ref) (ref, int) 311 | "syscall/js.valuePrepareString": (sp) => { 312 | const str = encoder.encode(String(loadValue(sp + 8))); 313 | storeValue(sp + 16, str); 314 | setInt64(sp + 24, str.length); 315 | }, 316 | 317 | // valueLoadString(v ref, b []byte) 318 | "syscall/js.valueLoadString": (sp) => { 319 | const str = loadValue(sp + 8); 320 | loadSlice(sp + 16).set(str); 321 | }, 322 | 323 | // func valueInstanceOf(v ref, t ref) bool 324 | "syscall/js.valueInstanceOf": (sp) => { 325 | mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); 326 | }, 327 | 328 | "debug": (value) => { 329 | console.log(value); 330 | }, 331 | } 332 | }; 333 | } 334 | 335 | async run(instance) { 336 | this._inst = instance; 337 | this._values = [ // TODO: garbage collection 338 | NaN, 339 | 0, 340 | null, 341 | true, 342 | false, 343 | global, 344 | this._inst.exports.mem, 345 | this, 346 | ]; 347 | this._refs = new Map(); 348 | this.exited = false; 349 | 350 | const mem = new DataView(this._inst.exports.mem.buffer) 351 | 352 | // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. 353 | let offset = 4096; 354 | 355 | const strPtr = (str) => { 356 | let ptr = offset; 357 | new Uint8Array(mem.buffer, offset, str.length + 1).set(encoder.encode(str + "\0")); 358 | offset += str.length + (8 - (str.length % 8)); 359 | return ptr; 360 | }; 361 | 362 | const argc = this.argv.length; 363 | 364 | const argvPtrs = []; 365 | this.argv.forEach((arg) => { 366 | argvPtrs.push(strPtr(arg)); 367 | }); 368 | 369 | const keys = Object.keys(this.env).sort(); 370 | argvPtrs.push(keys.length); 371 | keys.forEach((key) => { 372 | argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); 373 | }); 374 | 375 | const argv = offset; 376 | argvPtrs.forEach((ptr) => { 377 | mem.setUint32(offset, ptr, true); 378 | mem.setUint32(offset + 4, 0, true); 379 | offset += 8; 380 | }); 381 | 382 | this._inst.exports.run(argc, argv); 383 | if (this.exited) { 384 | this._resolveExitPromise(); 385 | } 386 | await this._exitPromise; 387 | } 388 | 389 | _resume() { 390 | if (this.exited) { 391 | throw new Error("Go program has already exited"); 392 | } 393 | this._inst.exports.resume(); 394 | if (this.exited) { 395 | this._resolveExitPromise(); 396 | } 397 | } 398 | 399 | _makeFuncWrapper(id) { 400 | const go = this; 401 | return function () { 402 | const event = { id: id, this: this, args: arguments }; 403 | go._pendingEvent = event; 404 | go._resume(); 405 | return event.result; 406 | }; 407 | } 408 | } 409 | })(); 410 | 411 | async function runApp(filePath) { 412 | if (!WebAssembly.instantiateStreaming) { 413 | // polyfill 414 | WebAssembly.instantiateStreaming = async (resp, importObject) => { 415 | const source = await (await resp).arrayBuffer(); 416 | return await WebAssembly.instantiate(source, importObject); 417 | }; 418 | } 419 | const go = new Go(); 420 | const { 421 | module: mod, 422 | instance: inst 423 | } = await WebAssembly.instantiateStreaming(fetch(filePath), go.importObject); 424 | await go.run(inst); 425 | await WebAssembly.instantiate(mod, go.importObject); // reset instance 426 | } 427 | -------------------------------------------------------------------------------- /index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syumai/go-wasm-todo-list/43e73a7bfd4467de75dc7634f9613724602c2df6/index.wasm -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "syscall/js" 5 | 6 | "github.com/syumai/go-hyperscript/dom" 7 | h "github.com/syumai/go-hyperscript/hyperscript" 8 | "github.com/syumai/go-wasm-todo-list/components" 9 | "github.com/syumai/go-wasm-todo-list/store" 10 | ) 11 | 12 | var ( 13 | renderer = dom.NewRenderer() 14 | body = js.Global().Get("document").Get("body") 15 | state = store.NewState() 16 | ) 17 | 18 | var ( 19 | appendToDo = h.FuncOf(func(this h.Value, args []h.Value) interface{} { 20 | event := args[0] 21 | event.Call("preventDefault") 22 | state.AppendToDo(state.Title) 23 | state.SetTitle("") 24 | return nil 25 | }) 26 | 27 | setTitle = h.FuncOf(func(this h.Value, args []h.Value) interface{} { 28 | event := args[0] 29 | event.Call("preventDefault") 30 | state.SetTitle(event.Get("target").Get("value").String()) 31 | return nil 32 | }) 33 | ) 34 | 35 | func render() { 36 | app := h.H("div", nil, 37 | h.H("h2", nil, h.Text("ToDo List")), 38 | h.H(components.ToDo, h.Object{ 39 | "toDos": state.ToDos, 40 | "title": state.Title, 41 | "setTitle": setTitle, 42 | "appendToDo": appendToDo, 43 | "updateToDo": func(id int, done bool) { state.UpdateToDo(id, done) }, 44 | }), 45 | h.H("a", h.Object{"href": "https://github.com/syumai/go-wasm-todo-list/"}, 46 | h.Text("Show the code on GitHub"), 47 | ), 48 | ) 49 | renderer.Render(app, body) 50 | } 51 | 52 | func main() { 53 | state.Subscribe(render) 54 | render() 55 | select {} 56 | } 57 | -------------------------------------------------------------------------------- /store/helpers.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | func SeparateToDos(toDos []*ToDo) (doingToDos, doneToDos []*ToDo) { 4 | for _, toDo := range toDos { 5 | if toDo.Done { 6 | doneToDos = append(doneToDos, toDo) 7 | } else { 8 | doingToDos = append(doingToDos, toDo) 9 | } 10 | } 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /store/mutations.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | func (s *State) SetTitle(title string) { 4 | defer s.onUpdated() 5 | 6 | s.Title = title 7 | } 8 | 9 | func (s *State) AppendToDo(title string) { 10 | defer s.onUpdated() 11 | 12 | s.LastID++ 13 | s.ToDos = append(s.ToDos, &ToDo{ 14 | Title: title, 15 | ID: s.LastID, 16 | }) 17 | } 18 | 19 | func (s *State) UpdateToDo(id int, done bool) { 20 | defer s.onUpdated() 21 | 22 | var t *ToDo 23 | for i := 0; i < len(s.ToDos); i++ { 24 | p := s.ToDos[i] 25 | if p.ID == id { 26 | t = p 27 | break 28 | } 29 | } 30 | if t == nil { 31 | return 32 | } 33 | t.Done = done 34 | } 35 | -------------------------------------------------------------------------------- /store/state.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | type State struct { 4 | LastID int 5 | Title string 6 | ToDos []*ToDo 7 | hooks []func() 8 | } 9 | 10 | func NewState() *State { 11 | return &State{} 12 | } 13 | 14 | func (s *State) Subscribe(f func()) { 15 | s.hooks = append(s.hooks, f) 16 | } 17 | 18 | func (s *State) onUpdated() { 19 | for _, hook := range s.hooks { 20 | hook() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /store/todo.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | type ToDo struct { 4 | Title string 5 | Done bool 6 | ID int 7 | } 8 | -------------------------------------------------------------------------------- /style/style.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | type Prop [2]string 4 | 5 | func Style(styles ...Prop) string { 6 | if len(styles) < 2 { 7 | return "" 8 | } 9 | var styleStr string 10 | for _, style := range styles { 11 | styleStr += style[0] + ": " + style[1] + ";" 12 | } 13 | return styleStr 14 | } 15 | -------------------------------------------------------------------------------- /style/todo.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | var ( 4 | ListContainerStyle = Style( 5 | Prop{"width", "100%"}, 6 | Prop{"display", "flex"}, 7 | Prop{"flex-wrap", "wrap"}, 8 | ) 9 | 10 | ListStyle = Style( 11 | Prop{"width", "50%"}, 12 | Prop{"max-width", "250px"}, 13 | ) 14 | ) 15 | --------------------------------------------------------------------------------