From 5c429b1765ccee85b1f6000c9f265cfab568b447 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sun, 22 Nov 2020 15:03:49 +0800 Subject: [PATCH] Implement theming, decouple data management logic from views Extract data persistence and updates from views. Also use custom CSS files for theming. --- src/assets/global.css | 9 ++ src/assets/mobiledoc.css | 155 ++++++++++++++++++ src/assets/theme.css | 10 ++ src/components/Editor/Editor.tsx | 6 +- src/controllers/Folder.ts | 6 + src/controllers/Note.ts | 104 ++++++++++++ src/pages/_app.tsx | 4 +- src/pages/_document.tsx | 1 - src/pages/notes.tsx | 268 +++++++++++++++---------------- src/services/Controller.ts | 17 +- src/services/Storage.ts | 10 +- src/utilities/Instance.ts | 1 + 12 files changed, 441 insertions(+), 150 deletions(-) create mode 100644 src/assets/global.css create mode 100644 src/assets/mobiledoc.css create mode 100644 src/assets/theme.css create mode 100644 src/controllers/Folder.ts create mode 100644 src/controllers/Note.ts diff --git a/src/assets/global.css b/src/assets/global.css new file mode 100644 index 0000000..1e675a4 --- /dev/null +++ b/src/assets/global.css @@ -0,0 +1,9 @@ +:root { + background-color: var(--color-bg); + color: var(--color-fg); + font-family: var(--font-family-base), sans-serif; +} + +body { + margin: 0; +} diff --git a/src/assets/mobiledoc.css b/src/assets/mobiledoc.css new file mode 100644 index 0000000..176d951 --- /dev/null +++ b/src/assets/mobiledoc.css @@ -0,0 +1,155 @@ +/** + * Editor + */ + +.__mobiledoc-editor { + font-family: var(--font-family-body), serif; + margin: 1em 0; + color: #454545; + font-size: 1.2em; + line-height: 1.6em; + position: relative; + min-height: 1em; +} + +.__mobiledoc-editor:focus { + outline: none; +} + +.__mobiledoc-editor > * { + position: relative; +} + +.__mobiledoc-editor.__has-no-content:after { + content: attr(data-placeholder); + color: #bbb; + cursor: text; + position: absolute; + top: 0; +} + +.__mobiledoc-editor a { + color: var(--color-primary); + white-space: nowrap; +} + +.__mobiledoc-editor h1, +.__mobiledoc-editor h2, +.__mobiledoc-editor h3, +.__mobiledoc-editor h4, +.__mobiledoc-editor h5, +.__mobiledoc-editor h6 { + font-family: var(--font-family-base), sans-serif; + letter-spacing: -0.02em; +} + +.__mobiledoc-editor blockquote { + border-left: 4px solid var(--color-primary); + margin: 1em 0 1em -1.2em; + padding-left: 1.05em; + color: #a0a0a0; +} + +.__mobiledoc-editor img { + display: block; + max-width: 100%; + margin: 0 auto; +} + +.__mobiledoc-editor div, +.__mobiledoc-editor iframe { + max-width: 100%; +} + +.__mobiledoc-editor [data-md-text-align='left'] { + text-align: left; +} + +.__mobiledoc-editor [data-md-text-align='center'] { + text-align: center; +} + +.__mobiledoc-editor [data-md-text-align='right'] { + text-align: right; +} + +.__mobiledoc-editor [data-md-text-align='justify'] { + text-align: justify; +} + +.__mobiledoc-editor ol, +.__mobiledoc-editor ul { + list-style-position: inside; +} + +/** + * Cards + */ + +.__mobiledoc-card { + display: inline-block; +} + +/** + * Tooltips + */ + +@-webkit-keyframes fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } +} +@keyframes fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +.__mobiledoc-tooltip { + font-family: var(--font-family-base), sans-serif; + font-size: 0.7em; + white-space: nowrap; + position: absolute; + background-color: rgba(43,43,43,0.9); + border-radius: 3px; + line-height: 1em; + padding: 0.7em 0.9em; + color: #FFF; + -webkit-animation: fade-in 0.2s; + animation: fade-in 0.2s; +} + +.__mobiledoc-tooltip:before { + content: ''; + position: absolute; + left: 50%; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid rgba(43,43,43,0.9); + top: -5px; + margin-left: -5px; +} + +/* help keeps mouseover state when moving from link to tooltip */ +.__mobiledoc-tooltip:after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: -5px; + height: 5px; +} + +.__mobiledoc-tooltip a { + color: #FFF; + text-decoration: none; +} + +.__mobiledoc-tooltip a:hover { + text-decoration: underline; +} + +.__mobiledoc-tooltip__edit-link { + margin-left: 5px; + cursor: pointer; +} diff --git a/src/assets/theme.css b/src/assets/theme.css new file mode 100644 index 0000000..bd4e0b1 --- /dev/null +++ b/src/assets/theme.css @@ -0,0 +1,10 @@ +:root { + --color-positive: #222; + --color-negative: #fff; + --color-accent: #7c47d2; + --color-bg: var(--color-negative); + --color-fg: var(--color-positive); + --color-primary: var(--color-accent); + --font-family-base: system-ui; + --font-family-body: Georgia; +} diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 6253dcf..14a52f3 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -134,7 +134,11 @@ const Editor: React.FC = ({ 1. - + ) } diff --git a/src/controllers/Folder.ts b/src/controllers/Folder.ts new file mode 100644 index 0000000..cbb232a --- /dev/null +++ b/src/controllers/Folder.ts @@ -0,0 +1,6 @@ +import * as Storage from '../services/Storage' + +export const loadFolders = async ({ setFolders, }) => { + const theFolders = await Storage.loadFolders() + setFolders(theFolders) +} diff --git a/src/controllers/Note.ts b/src/controllers/Note.ts new file mode 100644 index 0000000..52ad6b6 --- /dev/null +++ b/src/controllers/Note.ts @@ -0,0 +1,104 @@ +import * as Storage from '../services/Storage' + +const save = async ({ stateRef, router, id, setNotes, }) => { + stateRef.current.updatedAt = new Date().toISOString() + const newNote = await Storage.saveNote(stateRef.current) + if (router.query.id !== id) { + await router.replace( + { + pathname: '/notes/[id]', + query: { id, }, + }, + undefined, + { + shallow: true, + } + ) + } + setNotes(oldNotes => { + let notes + if (oldNotes.some((a) => a.id === id)) { + notes = oldNotes.map(n => n.id === id ? newNote : n) + } else { + notes = [newNote, ...oldNotes] + } + return notes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + }) +} + +const triggerAutoSave = ({ + stateRef, + timeoutRef, + router, + id, + setNotes, +}) => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout(async () => { + await save({ stateRef, router, id, setNotes, }) + timeoutRef.current = null + }, 3000) +} + +export const updateContent = ({ + stateRef, + timeoutRef, + router, + id, + setNotes, +}) => e => { + stateRef.current.content = e + triggerAutoSave({ + stateRef, + timeoutRef, + router, + id, + setNotes, + }) +} + +export const updateTitle = ({ + stateRef, + timeoutRef, + router, + id, + setNotes, + setTitle, +}) => e => { + setTitle(stateRef.current.title = e.target.value) + triggerAutoSave({ + stateRef, + timeoutRef, + router, + id, + setNotes, + }) +} + +export const remove = ({ + setNotes, + notes, + router, +}) => note => async () => { + setNotes(notes.filter(n => n.id !== note.id)) + await router.replace( + { + pathname: '/notes', + }, + undefined, + { + shallow: true, + } + ) + const result = await Storage.deleteNote(note) + if (!result) { + setNotes(notes) + } +} + +export const load = async ({ setNotes, }) => { + const theNotes = await Storage.loadNotes() + setNotes(theNotes) +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index bf8cb15..acbdf45 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,5 +1,7 @@ import * as React from 'react' -import 'mobiledoc-kit/dist/mobiledoc.css' +import '../assets/theme.css' +import '../assets/global.css' +import '../assets/mobiledoc.css' const App = ({ Component, pageProps }) => ( diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 5451532..6f6c6d3 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -19,7 +19,6 @@ export default class MyDocument extends Document { ...initialProps, styles: ( - {initialProps.styles} {sheet.getStyleElement()} diff --git a/src/pages/notes.tsx b/src/pages/notes.tsx index f0f59da..4645b01 100644 --- a/src/pages/notes.tsx +++ b/src/pages/notes.tsx @@ -1,13 +1,14 @@ import * as React from 'react' import Head from 'next/head' import styled from 'styled-components' +import { Trash2, FilePlus, FolderPlus, FileText, GitBranch, User } from 'react-feather' +import { useRouter } from 'next/router' import Editor from '../components/Editor/Editor' -import * as Storage from '../services/Storage' import generateId from '../utilities/Id' import Link from 'next/link' import { formatDate } from '../utilities/Date' -import { useRouter } from 'next/router' -import { Trash2, FilePlus, FolderPlus, FileText, GitBranch, User } from 'react-feather' +import * as Note from '../controllers/Note' +import * as Folder from '../controllers/Folder' const Navbar = styled('aside')({ width: 360, @@ -15,7 +16,8 @@ const Navbar = styled('aside')({ position: 'fixed', top: 0, left: -360, - backgroundColor: 'yellow', + backgroundColor: 'var(--color-fg)', + color: 'var(--color-bg)', '@media (min-width: 1080px)': { width: `${100 / 3}%`, left: 0, @@ -36,6 +38,7 @@ const PrimaryNavItems = styled('nav')({ const SecondaryNavItems = styled('nav')({ height: '100%', position: 'relative', + backgroundColor: 'var(--color-bg)', '::before': { content: "''", display: 'block', @@ -44,9 +47,8 @@ const SecondaryNavItems = styled('nav')({ width: '100%', height: '100%', position: 'absolute', - zIndex: -1, - backgroundColor: 'white', - opacity: 0.5, + backgroundColor: 'black', + opacity: 0.03125, }, '@media (min-width: 1080px)': { flex: 'auto', @@ -57,6 +59,7 @@ const SecondaryNavItemsOverflow = styled('div')({ overflow: 'auto', width: '100%', height: '100%', + position: 'relative', }) const Main = styled('main')({ @@ -101,6 +104,7 @@ const TitleInput = styled('input')({ font: 'inherit', fontSize: '3rem', fontWeight: 'bold', + color: 'inherit', outline: 0, }) @@ -123,6 +127,7 @@ const NoteLinkTitle = styled('strong')({ const LinkContainer = styled('div')({ position: 'relative', + color: 'var(--color-primary, blue)', }) const NoteActions = styled('div')({ @@ -145,13 +150,26 @@ const NoteAction = styled('button')({ }) const NoteLinkBackground = styled('span')({ - opacity: 0.125, - backgroundColor: 'currentColor', - top: 0, - left: 0, - width: '100%', - height: '100%', - position: 'absolute', + '::before': { + content: "''", + position: 'absolute', + top: 0, + left: 0, + width: '0.25rem', + height: '100%', + display: 'block', + backgroundColor: 'currentColor', + }, + '::after': { + content: "''", + opacity: 0.125, + backgroundColor: 'currentColor', + top: 0, + left: 0, + width: '100%', + height: '100%', + position: 'absolute', + }, }) const NewIcon = styled(FilePlus)({ @@ -196,6 +214,7 @@ const PostPrimary = styled('div')({ type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, } const Notes = ({ id: idProp }) => { + // TODO remove extra state for ID const [id, setId, ] = React.useState(idProp) const [title, setTitle, ] = React.useState('') const [notes, setNotes, ] = React.useState(null) @@ -204,64 +223,12 @@ const Notes = ({ id: idProp }) => { const timeoutRef = React.useRef(null) const router = useRouter() - const autoSave = () => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current) - } - timeoutRef.current = window.setTimeout(async () => { - const newNote = await Storage.saveNote({ - ...stateRef.current, - updatedAt: (stateRef.current.updatedAt = new Date().toISOString()), - }) - if (router.query.id !== id) { - await router.push(`/notes/${id}`, undefined, { shallow: true }) - } - setNotes(oldNotes => { - let notes - if (oldNotes.some((a) => a.id === id)) { - notes = oldNotes.map(n => n.id === id ? newNote : n) - } else { - notes = [newNote, ...oldNotes] - } - return notes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - }) - timeoutRef.current = null - }, 3000) - } - - const handleEditorChange = e => { - stateRef.current.content = e - autoSave() - } - - const handleTitleChange = e => { - stateRef.current.title = e.target.value - setTitle(e.target.value) - autoSave() - } - - const deleteNote = note => async () => { - setNotes(notes.filter(n => n.id !== note.id)) - const result = await Storage.deleteNote(note) - if (!result) { - setNotes(notes) - } - } - React.useEffect(() => { - const loadNotes = async () => { - const theNotes = await Storage.loadNotes() - setNotes(theNotes) - } - loadNotes() + Note.load({ setNotes }) }, []) React.useEffect(() => { - const loadFolders = async () => { - const theFolders = await Storage.loadFolders() - setFolders(theFolders) - } - loadFolders() + Folder.loadFolders({ setFolders }) }, []) React.useEffect(() => { @@ -280,7 +247,7 @@ const Notes = ({ id: idProp }) => { return ( - { idProp === undefined ? 'Notes | New Note' : `Notes | ${title.length > 0 ? title : '(untitled)'}`} + { idProp === undefined ? 'Notes | New Note' : `Notes | Edit Note - ${title.length > 0 ? title : '(untitled)'}`} @@ -324,57 +291,63 @@ const Notes = ({ id: idProp }) => { - - - - - - - Personal - - - - - - - - - - - - Create Folder - - - - - - - - - - - - Create Note - - - - - + + + + + + + + Personal + + + + + + + + + + + + + + Create Folder + + + + + + + + + + + + + + Create Note + + + + + + { Array.isArray(notes!) && notes.map(n => ( @@ -417,7 +390,7 @@ const Notes = ({ id: idProp }) => { @@ -425,23 +398,25 @@ const Notes = ({ id: idProp }) => { )) } - - - - - - - View Binned Notes - - - - - + + + + + + + + View Binned Notes + + + + + + @@ -457,7 +432,14 @@ const Notes = ({ id: idProp }) => { { @@ -477,7 +459,13 @@ const Notes = ({ id: idProp }) => { autoFocus={false} key={id} content={stateRef.current ? stateRef.current.content : undefined} - onChange={handleEditorChange} + onChange={Note.updateContent({ + stateRef, + timeoutRef, + router, + id, + setNotes, + })} placeholder="Start typing here." /> diff --git a/src/services/Controller.ts b/src/services/Controller.ts index 3a2be29..cca99af 100644 --- a/src/services/Controller.ts +++ b/src/services/Controller.ts @@ -22,9 +22,14 @@ export const collection = (Model, Service) => async (req, res) => { res.statusCode = status res.json(data) } catch (err) { + console.error(err) const { status, data, } = err res.statusCode = status - res.json(data) + if (data && status !== 204) { + res.json(data) + return + } + res.end() } } @@ -57,9 +62,13 @@ export const item = (Model, Service) => async (req, res) => { } res.end() } catch (err) { - console.log('ERROR', err) + console.error(err) const { status, data, } = err - res.statusCode = status - res.json(data) + res.statusCode = status || 500 + if (data && status !== 204) { + res.json(data) + return + } + res.end() } } diff --git a/src/services/Storage.ts b/src/services/Storage.ts index cf928f5..3bcce15 100644 --- a/src/services/Storage.ts +++ b/src/services/Storage.ts @@ -1,5 +1,7 @@ import { addTime, TimeDivision } from '../utilities/Date' import * as Serialization from '../utilities/Serialization' +import NoteModel from '../../models/Note' +import InferModel from '../utilities/Instance' import LocalStorage from './LocalStorage' type StorageParams = { @@ -7,6 +9,8 @@ type StorageParams = { url: string, } +type Note = InferModel + type LoadItems = >(params: StorageParams) => () => Promise const loadItems: LoadItems = >(params) => async (): Promise => { @@ -99,8 +103,8 @@ const deleteItem: DeleteItem = >(params) => as return response.status === 204 } -export const loadNotes = loadItems({ id: 'notes', url: '/api/notes' }) +export const loadNotes = loadItems({ id: 'notes', url: '/api/notes' }) export const loadFolders = loadItems({ id: 'folders', url: '/api/folders' }) -export const saveNote = saveItem({ id: 'notes', url: '/api/notes' }) +export const saveNote = saveItem({ id: 'notes', url: '/api/notes' }) export const saveFolder = saveItem({ id: 'folders', url: '/api/folders' }) -export const deleteNote = deleteItem({ id: 'notes', url: '/api/notes' }) +export const deleteNote = deleteItem({ id: 'notes', url: '/api/notes' }) diff --git a/src/utilities/Instance.ts b/src/utilities/Instance.ts index c3dfeef..f65767b 100644 --- a/src/utilities/Instance.ts +++ b/src/utilities/Instance.ts @@ -22,6 +22,7 @@ type Model = { type InferType = ( V extends typeof Sequelize.STRING ? string : V extends typeof Sequelize.TEXT ? string : + V extends ReturnType ? string : V extends typeof Sequelize.DATE ? Date : V extends typeof Sequelize.DATEONLY ? Date : V extends typeof Sequelize.UUIDV4 ? string :