Extract data persistence and updates from views. Also use custom CSS files for theming.feature/transactions
@@ -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; | |||
} |
@@ -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; | |||
} |
@@ -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; | |||
} |
@@ -134,7 +134,11 @@ const Editor: React.FC<Props> = ({ | |||
</ToolbarItem> | |||
<ToolbarItem><StyledSectionButton tag="ol">1.</StyledSectionButton></ToolbarItem> | |||
</ToolbarWrapper> | |||
<MobileDocEditor /> | |||
<MobileDocEditor | |||
style={{ | |||
color: 'inherit', | |||
}} | |||
/> | |||
</Container> | |||
) | |||
} | |||
@@ -0,0 +1,6 @@ | |||
import * as Storage from '../services/Storage' | |||
export const loadFolders = async ({ setFolders, }) => { | |||
const theFolders = await Storage.loadFolders() | |||
setFolders(theFolders) | |||
} |
@@ -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) | |||
} |
@@ -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} /> | |||
@@ -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> | |||
@@ -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> | |||
@@ -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() | |||
} | |||
} |
@@ -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' }) |
@@ -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 : | |||