From 2e3701909d080d8353278dc687036e10a4fda04c Mon Sep 17 00:00:00 2001 From: Kevin Cameron Date: Sun, 25 Mar 2018 10:45:55 -0700 Subject: [PATCH] Merge localstorage --- src/components/header/index.js | 3 + src/components/home/index.css | 35 ------- src/components/home/index.js | 34 +++---- src/components/home/list.css | 39 ++++++++ src/components/home/list.js | 22 +++++ src/components/layout/index.css | 18 ++++ src/components/layout/index.js | 13 +++ src/components/lyrics/index.js | 57 +++++------ src/components/modal/title.js | 6 +- src/index.js | 5 +- src/routes.js | 25 +---- src/state/db.js | 168 ++++++++++++++++++++++++++++++++ src/state/index.js | 13 ++- src/state/modal.js | 2 +- src/state/song.js | 101 ++++++++++--------- 15 files changed, 383 insertions(+), 158 deletions(-) create mode 100644 src/components/home/list.css create mode 100644 src/components/home/list.js create mode 100644 src/state/db.js diff --git a/src/components/header/index.js b/src/components/header/index.js index 20f2593..4b726c8 100644 --- a/src/components/header/index.js +++ b/src/components/header/index.js @@ -16,6 +16,9 @@ export default { }; }, onbeforeremove : (vnode) => animResolve(vnode.dom, css.headerOut), + onremove : () => { + delete state.header; + }, view : () => m("div", { class : css.headerIn }, m("h1", { class : css.title }, state.song ? state.song.title : state.appName), diff --git a/src/components/home/index.css b/src/components/home/index.css index f8fa377..094799a 100644 --- a/src/components/home/index.css +++ b/src/components/home/index.css @@ -96,38 +96,3 @@ color: #fff !important; --color: colors.cta; } - -.list { - max-width: 30em; - margin: 1.6em auto 0; - - text-align: left; - - h3 { - margin-bottom: 0.2em; - border-bottom: solid 1px #fff5; - - color: colors.ctaLight; - font: bold 1.25em/1 fontSans; - } - - a { - display: block; - - font-family: fontSerif; - text-transform: uppercase; - text-decoration: none; - font-size: 1.35em; - - &:hover { - text-decoration: underline; - } - - span { - display: inline-block; - padding-left: 0.3em; - opacity: 0.6; - font-size: 0.8em; - } - } -} diff --git a/src/components/home/index.js b/src/components/home/index.js index fb3d9bc..dabc687 100644 --- a/src/components/home/index.js +++ b/src/components/home/index.js @@ -1,12 +1,18 @@ import m from "mithril"; import state from "../../state"; +import db from "../../state/db"; import css from "./index.css"; import logo from "../icons/lyrite-logo.svg"; +import list from "./list"; export default { + oninit : (vnode) => { + vnode.state.defaultSongs = db.get("songs?default=true"); + vnode.state.customSongs = db.get("songs?default=undefined"); + }, view : (vnode) => [ m("div", { class : css.home }, @@ -44,7 +50,7 @@ export default { m("button", { class : css.loadBtn, onclick : () => { - let slug = state.action("LOAD SONG", vnode.state.lyricsValue); + let slug = state.action("IMPORT SONG LYRICS", vnode.state.lyricsValue); delete vnode.state.textarea; delete vnode.state.load; @@ -57,24 +63,14 @@ export default { ), // loaded songs list - m("div", { class : css.list }, - m("h3", "or choose a song"), - state.songs ? state.songs.map((song, idx) => - m("a", { - onclick : () => { - console.log("open song"); - state.action("OPEN SONG", idx); - }, - oncreate: m.route.link, - href : `/${song.slug}` - }, - song.title, - song.artist ? - m("span", " - ", song.artist ) : - null - ) - ) : null - ) + Object.keys(vnode.state.customSongs).length ? + m(list, { songs : vnode.state.customSongs, header : "your songs"}) : + null, + + // loaded songs list + Object.keys(vnode.state.defaultSongs).length ? + m(list, { songs : vnode.state.defaultSongs, header : "default songs"}) : + null ) ] }; diff --git a/src/components/home/list.css b/src/components/home/list.css new file mode 100644 index 0000000..fb355dd --- /dev/null +++ b/src/components/home/list.css @@ -0,0 +1,39 @@ +@value * as colors from "../../palette.css"; +@value fontSans, fontSerif from "../../index.css"; + +.list { + max-width: 30em; + margin: 1.6em auto 0; + + text-align: left; + + h3 { + margin-bottom: 0.2em; + padding-bottom: 0.3em; + border-bottom: solid 1px #fff5; + + color: colors.ctaLight; + font: bold 1.15em/1 fontSans; + } + + a { + display: block; + + font-family: fontSerif; + text-transform: uppercase; + text-decoration: none; + font-size: 1.25em; + line-height: 1.25; + + &:hover { + text-decoration: underline; + } + + span { + display: inline-block; + padding-left: 0.3em; + opacity: 0.6; + font-size: 0.8em; + } + } +} diff --git a/src/components/home/list.js b/src/components/home/list.js new file mode 100644 index 0000000..86038eb --- /dev/null +++ b/src/components/home/list.js @@ -0,0 +1,22 @@ +import m from "mithril"; + +import css from "./list.css"; + +export default { + view : (vnode) => + m("div", { class : css.list }, + m("h3", vnode.attrs.header), + + Object.keys(vnode.attrs.songs).map((slug) => + m("a", { + oncreate : m.route.link, + href : `/${vnode.attrs.songs[slug].slug}` + }, + vnode.attrs.songs[slug].title, + vnode.attrs.songs[slug].artist ? + m("span", " - ", vnode.attrs.songs[slug].artist ) : + null + ) + ) + ) +}; diff --git a/src/components/layout/index.css b/src/components/layout/index.css index 7b966ea..3de958c 100644 --- a/src/components/layout/index.css +++ b/src/components/layout/index.css @@ -23,4 +23,22 @@ opacity: 0.3; font: normal 0.8em/1 monospace; +} + +.clear { + position: fixed; + bottom: 1em; + left: 1em; +} + +.debug { + position: fixed; + top: 0; + opacity: 0.2; + max-height: 100vh; + width: 400px; + overflow-y: auto; + font-size: 0.75em; + z-index: -1; + pointer-events: none; } \ No newline at end of file diff --git a/src/components/layout/index.js b/src/components/layout/index.js index 90df549..ff4306b 100644 --- a/src/components/layout/index.js +++ b/src/components/layout/index.js @@ -15,6 +15,19 @@ export default { vnode.children, + state.debug ? [ + m("button", { + class : css.clear, + onclick : () => { + state.action("CLEAR DB"); + + } + }, "clear"), + + m("pre", { class : css.debug }, JSON.stringify(state, null, 2)) + ] : + null, + m("div", { class : css.bug }, m("a", { href : state.githubHref, diff --git a/src/components/lyrics/index.js b/src/components/lyrics/index.js index 7960ff3..baed073 100644 --- a/src/components/lyrics/index.js +++ b/src/components/lyrics/index.js @@ -11,37 +11,38 @@ function addBr(text) { export default { oninit : () => { - if(state.song.untitled) { - state.action("OPEN TITLE MODAL"); - } + // if(state.song.untitled) { + // state.action("OPEN TITLE MODAL"); + // } }, - view : () => m("div", { class : css.lyredit }, - m("div", { - class : state.edit ? css.lyricsEdit : css.lyrics, - style : { - fontSize : `${state.font.size}em`, - columnCount : state.cols.count - } - }, - state.song.lyrics - .map((part, idx) => - m("p", { - id : part.hash, - class : [ - state.selected === idx ? css.lineSelected : css.line, - part.style ? css[`s${part.style.idx}`] : null - ].join(" "), + view : () => + m("div", { class : css.lyredit }, + m("div", { + class : state.edit ? css.lyricsEdit : css.lyrics, + style : { + fontSize : `${state.font.size}em`, + columnCount : state.cols.count + } + }, + state.song.lyrics + .map((part, idx) => + m("p", { + id : part.hash, + class : [ + state.selected === idx ? css.lineSelected : css.line, + part.style ? css[`s${part.style.idx}`] : null + ].join(" "), - onclick : () => { - state.action("CLICK LYRIC", idx); - } - }, + onclick : () => { + state.action("CLICK LYRIC", idx); + } + }, - m.trust(addBr(part.text)) + m.trust(addBr(part.text)) + ) ) - ) - ), + ), - state.edit ? m(edit) : null - ) + state.edit ? m(edit) : null + ) }; diff --git a/src/components/modal/title.js b/src/components/modal/title.js index 21656ae..acc3276 100644 --- a/src/components/modal/title.js +++ b/src/components/modal/title.js @@ -14,7 +14,7 @@ export default { return; } - state.action("ADD TITLE", vnode.state.value); + state.action("ADD TITLE MODAL", vnode.state.value); } }, m("input", { @@ -37,7 +37,9 @@ export default { ), m("button", { class : css.cancel, - onclick : () => { + onclick : (e) => { + e.preventDefault(); + state.action("CLOSE MODAL"); } }, diff --git a/src/index.js b/src/index.js index 95e1734..3b64b2d 100644 --- a/src/index.js +++ b/src/index.js @@ -12,9 +12,12 @@ import "./favicon.ico"; const mountEl = document.getElementById("mount"); -m.route.prefix(""); +// state.debug = true; +state.action("IMPORT DEFAULT SONGS"); +m.route.prefix(""); m.route(mountEl, "/", routes); + window.m = m; window.state = state; diff --git a/src/routes.js b/src/routes.js index 1812fd2..64bbac7 100644 --- a/src/routes.js +++ b/src/routes.js @@ -10,33 +10,16 @@ import error from "./components/error"; import home from "./components/home"; export default { - "/" : { + "/" : { onmatch : () => { - if(!state.songs) { - state.action("LOAD DEFAULT SONGS"); - } else { - state.action("CLOSE SONG"); - } + state.action("CLOSE SONG"); }, render : () => m(layout, m(home)) }, "/:slug" : { - onmatch : (args) => { - if(!state.songs) { - state.action("LOAD DEFAULT SONGS"); - } - - let songIdx = state.action("GET SONG IDX FROM SLUG", args.slug); - - if(!songIdx && songIdx !== 0) { - return error; - } - - state.action("OPEN SONG", songIdx); - - return lyrics; - }, + onmatch : (args) => + state.action("LOAD SONG BY SLUG", args.slug) ? lyrics : error, render : (comp) => m(layout, { header : true }, m(comp.tag)) } }; diff --git a/src/state/db.js b/src/state/db.js new file mode 100644 index 0000000..9100937 --- /dev/null +++ b/src/state/db.js @@ -0,0 +1,168 @@ +import { get, set } from "object-path"; + +// Table class, one created for each top level +function Table(key) { + if(!key) { + throw new Error("Must provide key"); + } + + // Private methods + function _getTable() { + const data = JSON.parse(localStorage.getItem(key)) || {}; + + return data; + } + + function _setTable(data) { + return localStorage.setItem(key, JSON.stringify(data)); + } + + // init table data if needed + if(!_getTable()) { + _setTable({}); + } + + // Exposed API + this.get = (path) => { + const data = _getTable(); + + return get(data, path); + }; + + this.set = (path, newData) => { + const data = _getTable(); + + set(data, path, newData); + + return _setTable(data); + }; + + this.del = (path) => { + const data = _getTable(); + + if(!path) { + return _setTable({}); + } + + set(data, path, undefined); + + return _setTable(data); + }; + + this.log = () => { + console.log(localStorage.getItem(key)); + }; +} + +// create Tables +const db = { + songs : new Table("songs") +}; + +function parseQueryParams(queryParams) { + if(typeof queryParams !== "string") { + return queryParams; + } + + return queryParams.split("&") + .reduce((acc, keyVal) => { + let [ key, value ] = keyVal.split("="); + + // value is implicit true (for a query like post?untitied) + if(!value) { + value = true; + } else { + // parse "true", "false" + value = parseValue(value); + } + + acc[key] = value; + + return acc; + }, {}); +} + +// pulls first key off path +function parseQuery(query) { + const [ keyPath, queryParams ] = query.split("?"); + + const [ key, path ] = keyPath.split("."); + + return { + key, + path, + query : queryParams, + queryParams : parseQueryParams(queryParams) + }; +} + +function applyQueryParams(data, queryParams) { + let filtered = Object.keys(data); + + Object.keys(queryParams).forEach((queryKey) => { + filtered = filtered.filter((slug) => { + // special case, eg songs?untitled=undefined + if(queryParams[queryKey] === "undefined") { + return data[slug][queryKey] === undefined; + } + + return data[slug][queryKey] === queryParams[queryKey]; + }); + }); + + return filtered.reduce((acc, cur) => { + acc[cur] = data[cur]; + + return acc; + }, {}); +} + +function parseValue(value) { + if(value === "true") { + value = true; + } else if(value === "false") { + value = false; + } + // todo: int, float + + return value; +} + +export default { + get : (query) => { + const parsed = parseQuery(query); + + if(!parsed.key) { + return; + } + + const data = db[parsed.key].get(parsed.path); + + if(!parsed.queryParams) { + return data; + } + + return applyQueryParams(data, parsed.queryParams); + }, + set : (query, data) => { + const parsed = parseQuery(query); + + if(!parsed.key) { + return; + } + + return db[parsed.key].set(parsed.path, data); + }, + del : (query) => { + const parsed = parseQuery(query); + + if(!parsed.key) { + return; + } + + return db[parsed.key].del(parsed.path); + }, + clear : () => { + localStorage.clear(); + } +}; diff --git a/src/state/index.js b/src/state/index.js index 37b47e0..fbabe38 100644 --- a/src/state/index.js +++ b/src/state/index.js @@ -4,15 +4,18 @@ import tools from "./tools"; import song from "./song"; import modal from "./modal"; +import db from "./db"; + const State = { appName : "Lyrite", tagline : "a tool to format lyrics", githubHref : "https://github.com/kevinkace/lyrite", styles : [ "s0", "s1", "s2", "s3", "s4", "s5" ], - font : { size : "1.3" }, + font : { size : 1.3 }, cols : { count : 3 }, + // added to doc in script via webpack ver, // eslint-disable-line no-undef error : (err) => { @@ -32,9 +35,11 @@ const State = { } }; - - -State.actions = Object.assign({}, tools(State), song(State), modal(State)); +State.actions = Object.assign({ + "CLEAR DB" : () => { + db.clear(); + } +}, tools(State), song(State), modal(State)); State.action = (name, value) => State.actions[name](value); window.state = State; diff --git a/src/state/modal.js b/src/state/modal.js index f6911e6..df8a740 100644 --- a/src/state/modal.js +++ b/src/state/modal.js @@ -11,7 +11,7 @@ export default (State) => ({ m.redraw(); }, - "ADD TITLE" : (title) => { + "ADD TITLE MODAL" : (title) => { State.action("SET TITLE", title); State.action("CLOSE MODAL"); diff --git a/src/state/song.js b/src/state/song.js index c37c6d4..b7dec6a 100644 --- a/src/state/song.js +++ b/src/state/song.js @@ -2,7 +2,9 @@ import eol from "eol"; import hash from "string-hash"; import slugify from "slugify"; -import songs from "./songs"; +import db from "./db"; + +import defaultSongs from "./songs"; const titleSplit = "\n\n---\n\n"; @@ -15,74 +17,79 @@ function parseLyricString(lyricString) { })); } -function getSongParts(songString) { - return eol.lf(songString).split(titleSplit); +function parseSongString(songString) { + const [ meta, lyricString ] = eol.lf(songString).split(titleSplit); + const [ title, artist ] = meta.split("\n"); + + return { + slug : slugify(title), + title, + artist, + lyricString : lyricString, + lyrics : parseLyricString(lyricString) + }; } export default (State) => ({ - "LOAD SONG" : (song) => { - let newSong = {}; - - State.untitled = State.untitled || 0; - State.songs = State.songs || []; - - if(typeof song === "object") { - newSong = song; - } else { - newSong.untitled = true; - newSong.title = `untitled ${++State.untitled}`; - newSong.lyricString = song; - } + // imports default songs to DB + "IMPORT DEFAULT SONGS" : () => { + const savedSongs = db.get("songs") || []; - newSong.lyrics = parseLyricString(newSong.lyricString); - newSong.slug = slugify(newSong.title); + // add each default song + defaultSongs.forEach((songString) => { + const songObj = parseSongString(songString); - State.songs.push(newSong); + // don't add if already in DB + if(songObj.slug in savedSongs) { + return; + } - return newSong.slug; - }, + songObj.default = true; - "SET TITLE" : (title) => { - State.song.title = title; - State.song.slug = slugify(State.song.title); + db.set(`songs.${songObj.slug}`, songObj); + }); }, - "LOAD DEFAULT SONGS" : () => { - songs.forEach((songString) => { - const parts = getSongParts(songString); + // import song + "IMPORT SONG LYRICS" : (lyricString) => { + const untitledSongs = db.get("songs?untitled"); + const title = `untitled ${Object.keys(untitledSongs).length + 1}`; + const slug = slugify(title); - State.action("LOAD SONG", { - title : parts[0].split("\n")[0], - artist : parts[0].split("\n")[1], - lyricString : parts[1] - }); - }); - }, + let songObj = { + slug, + title, + lyricString, + lyrics : parseLyricString(lyricString), + untitled : true + }; + + db.set(`songs.${slug}`, songObj); - "OPEN SONG" : (idx) => { - State.song = State.songs[idx]; + return slug; }, - "GET SONG IDX FROM SLUG" : (slug) => { - let songIdx; + "SET TITLE" : (title) => { + const oldSlug = State.song.slug; - State.songs.some((song, idx) => { - if(song.slug !== slug) { - return false; - } + State.song.title = title; + State.song.slug = slugify(State.song.title); + delete State.song.untitled; - songIdx = idx; + db.set(`songs.${State.song.slug}`, State.song); + db.del(`songs.${oldSlug}`); + }, - return true; - }); + "LOAD SONG BY SLUG" : (slug) => { + State.song = db.get(`songs.${slug}`); - if(!songIdx && songIdx !== 0) { + if(!State.song) { State.error = "song not found"; return; } - return songIdx; + return State.song; }, "CLOSE SONG" : () => {