Browse Source

Implement theming, decouple data management logic from views

Extract data persistence and updates from views. Also use custom CSS files for theming.
feature/transactions
TheoryOfNekomata 3 years ago
parent
commit
5c429b1765
12 changed files with 441 additions and 150 deletions
  1. +9
    -0
      src/assets/global.css
  2. +155
    -0
      src/assets/mobiledoc.css
  3. +10
    -0
      src/assets/theme.css
  4. +5
    -1
      src/components/Editor/Editor.tsx
  5. +6
    -0
      src/controllers/Folder.ts
  6. +104
    -0
      src/controllers/Note.ts
  7. +3
    -1
      src/pages/_app.tsx
  8. +0
    -1
      src/pages/_document.tsx
  9. +128
    -140
      src/pages/notes.tsx
  10. +13
    -4
      src/services/Controller.ts
  11. +7
    -3
      src/services/Storage.ts
  12. +1
    -0
      src/utilities/Instance.ts

+ 9
- 0
src/assets/global.css View File

@@ -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;
}

+ 155
- 0
src/assets/mobiledoc.css View File

@@ -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;
}

+ 10
- 0
src/assets/theme.css View File

@@ -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;
}

+ 5
- 1
src/components/Editor/Editor.tsx View File

@@ -134,7 +134,11 @@ const Editor: React.FC<Props> = ({
</ToolbarItem>
<ToolbarItem><StyledSectionButton tag="ol">1.</StyledSectionButton></ToolbarItem>
</ToolbarWrapper>
<MobileDocEditor />
<MobileDocEditor
style={{
color: 'inherit',
}}
/>
</Container>
)
}


+ 6
- 0
src/controllers/Folder.ts View File

@@ -0,0 +1,6 @@
import * as Storage from '../services/Storage'

export const loadFolders = async ({ setFolders, }) => {
const theFolders = await Storage.loadFolders()
setFolders(theFolders)
}

+ 104
- 0
src/controllers/Note.ts View File

@@ -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)
}

+ 3
- 1
src/pages/_app.tsx View File

@@ -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 }) => (
<Component {...pageProps} />


+ 0
- 1
src/pages/_document.tsx View File

@@ -19,7 +19,6 @@ export default class MyDocument extends Document {
...initialProps,
styles: (
<React.Fragment>
<style>{`:root { font-family: system-ui, sans-serif } body { margin: 0 }`}</style>
{initialProps.styles}
{sheet.getStyleElement()}
</React.Fragment>


+ 128
- 140
src/pages/notes.tsx View File

@@ -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<number>(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 (
<React.Fragment>
<Head>
<title>{ idProp === undefined ? 'Notes | New Note' : `Notes | ${title.length > 0 ? title : '(untitled)'}`}</title>
<title>{ idProp === undefined ? 'Notes | New Note' : `Notes | Edit Note - ${title.length > 0 ? title : '(untitled)'}`}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar>
@@ -324,57 +291,63 @@ const Notes = ({ id: idProp }) => {
</PrimaryNavItems>
<SecondaryNavItems>
<SecondaryNavItemsOverflow>
<Link
href={{
pathname: '/profile',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Personal
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
<Link
href={{
pathname: '/folders/new',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Create Folder
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
<Link
href={{
pathname: '/notes',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewIcon />
<NoteLinkTitle>
Create Note
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
<LinkContainer>
<Link
href={{
pathname: '/profile',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Personal
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
</LinkContainer>
<LinkContainer>
<Link
href={{
pathname: '/folders/new',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Create Folder
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
</LinkContainer>
<LinkContainer>
<Link
href={{
pathname: '/notes',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewIcon />
<NoteLinkTitle>
Create Note
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
</LinkContainer>
{
Array.isArray(notes!)
&& notes.map(n => (
@@ -417,7 +390,7 @@ const Notes = ({ id: idProp }) => {
</Link>
<NoteActions>
<NoteAction
onClick={deleteNote(n)}
onClick={Note.remove({ setNotes, notes, router, })(n)}
>
<Trash2 />
</NoteAction>
@@ -425,23 +398,25 @@ const Notes = ({ id: idProp }) => {
</LinkContainer>
))
}
<Link
href={{
pathname: '/bin',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<BinIcon />
<NoteLinkTitle>
View Binned Notes
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
<LinkContainer>
<Link
href={{
pathname: '/bin',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<BinIcon />
<NoteLinkTitle>
View Binned Notes
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
</LinkContainer>
</SecondaryNavItemsOverflow>
</SecondaryNavItems>
</NavbarItems>
@@ -457,7 +432,14 @@ const Notes = ({ id: idProp }) => {
<TitleInput
placeholder="Title"
value={title}
onChange={handleTitleChange}
onChange={Note.updateTitle({
stateRef,
timeoutRef,
router,
id,
setNotes,
setTitle,
})}
/>
<PostMeta>
{
@@ -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."
/>
</React.Fragment>


+ 13
- 4
src/services/Controller.ts View File

@@ -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()
}
}

+ 7
- 3
src/services/Storage.ts View File

@@ -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<typeof NoteModel>

type LoadItems = <T extends Record<string, unknown>>(params: StorageParams) => () => Promise<T[]>

const loadItems: LoadItems = <T extends Record<string, unknown>>(params) => async (): Promise<T[]> => {
@@ -99,8 +103,8 @@ const deleteItem: DeleteItem = <T extends Record<string, unknown>>(params) => as
return response.status === 204
}

export const loadNotes = loadItems({ id: 'notes', url: '/api/notes' })
export const loadNotes = loadItems<Note>({ 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<Note>({ 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<Note>({ id: 'notes', url: '/api/notes' })

+ 1
- 0
src/utilities/Instance.ts View File

@@ -22,6 +22,7 @@ type Model = {
type InferType<V extends Sequelize.DataType> = (
V extends typeof Sequelize.STRING ? string :
V extends typeof Sequelize.TEXT ? string :
V extends ReturnType<typeof Sequelize.TEXT> ? string :
V extends typeof Sequelize.DATE ? Date :
V extends typeof Sequelize.DATEONLY ? Date :
V extends typeof Sequelize.UUIDV4 ? string :


Loading…
Cancel
Save