Make sure backup makes saving to local storage a priority.feature/transactions
@@ -7,7 +7,7 @@ import generateId from '../utilities/Id' | |||||
import Link from 'next/link' | import Link from 'next/link' | ||||
import { formatDate } from '../utilities/Date' | import { formatDate } from '../utilities/Date' | ||||
import { useRouter } from 'next/router' | import { useRouter } from 'next/router' | ||||
import { XCircle } from 'react-feather' | |||||
import { Trash2, FilePlus, FolderPlus } from 'react-feather' | |||||
const Navbar = styled('aside')({ | const Navbar = styled('aside')({ | ||||
width: 360, | width: 360, | ||||
@@ -71,10 +71,14 @@ const NoteLink = styled('a')({ | |||||
position: 'relative', | position: 'relative', | ||||
}) | }) | ||||
const NoteLinkTitle = styled('strong')({ | |||||
const NoteLinkPrimary = styled('div')({ | |||||
display: 'block', | display: 'block', | ||||
}) | }) | ||||
const NoteLinkTitle = styled('strong')({ | |||||
verticalAlign: 'middle', | |||||
}) | |||||
const LinkContainer = styled('div')({ | const LinkContainer = styled('div')({ | ||||
position: 'relative', | position: 'relative', | ||||
}) | }) | ||||
@@ -108,6 +112,21 @@ const NoteLinkBackground = styled('span')({ | |||||
position: 'absolute', | position: 'absolute', | ||||
}) | }) | ||||
const NewIcon = styled(FilePlus)({ | |||||
verticalAlign: 'middle', | |||||
marginRight: '0.5rem', | |||||
}) | |||||
const NewFolderIcon = styled(FolderPlus)({ | |||||
verticalAlign: 'middle', | |||||
marginRight: '0.5rem', | |||||
}) | |||||
const BinIcon = styled(Trash2)({ | |||||
verticalAlign: 'middle', | |||||
marginRight: '0.5rem', | |||||
}) | |||||
type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, } | type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, } | ||||
const Notes = ({ id: idProp }) => { | const Notes = ({ id: idProp }) => { | ||||
@@ -126,7 +145,6 @@ const Notes = ({ id: idProp }) => { | |||||
timeoutRef.current = window.setTimeout(async () => { | timeoutRef.current = window.setTimeout(async () => { | ||||
const newNote = await Storage.saveNote({ | const newNote = await Storage.saveNote({ | ||||
...stateRef.current, | ...stateRef.current, | ||||
title, | |||||
updatedAt: new Date().toISOString(), | updatedAt: new Date().toISOString(), | ||||
}) | }) | ||||
if (router.query.id !== id) { | if (router.query.id !== id) { | ||||
@@ -151,6 +169,7 @@ const Notes = ({ id: idProp }) => { | |||||
} | } | ||||
const handleTitleChange = e => { | const handleTitleChange = e => { | ||||
stateRef.current.title = e.target.value | |||||
setTitle(e.target.value) | setTitle(e.target.value) | ||||
autoSave() | autoSave() | ||||
} | } | ||||
@@ -184,10 +203,6 @@ const Notes = ({ id: idProp }) => { | |||||
setId(idProp || generateId()) | setId(idProp || generateId()) | ||||
}, [idProp]) | }, [idProp]) | ||||
React.useEffect(() => { | |||||
autoSave() | |||||
}, [title]) | |||||
return ( | return ( | ||||
<React.Fragment> | <React.Fragment> | ||||
<Head> | <Head> | ||||
@@ -195,6 +210,40 @@ const Notes = ({ id: idProp }) => { | |||||
<link rel="icon" href="/favicon.ico" /> | <link rel="icon" href="/favicon.ico" /> | ||||
</Head> | </Head> | ||||
<Navbar> | <Navbar> | ||||
<Link | |||||
href={{ | |||||
pathname: '/profile', | |||||
}} | |||||
passHref | |||||
> | |||||
<NoteLink> | |||||
<NavbarContainer> | |||||
<NoteLinkPrimary> | |||||
<NewFolderIcon /> | |||||
<NoteLinkTitle> | |||||
Personal | |||||
</NoteLinkTitle> | |||||
</NoteLinkPrimary> | |||||
</NavbarContainer> | |||||
</NoteLink> | |||||
</Link> | |||||
<Link | |||||
href={{ | |||||
pathname: '/folders/new', | |||||
}} | |||||
passHref | |||||
> | |||||
<NoteLink> | |||||
<NavbarContainer> | |||||
<NoteLinkPrimary> | |||||
<NewFolderIcon /> | |||||
<NoteLinkTitle> | |||||
Create Folder | |||||
</NoteLinkTitle> | |||||
</NoteLinkPrimary> | |||||
</NavbarContainer> | |||||
</NoteLink> | |||||
</Link> | |||||
<Link | <Link | ||||
href={{ | href={{ | ||||
pathname: '/notes', | pathname: '/notes', | ||||
@@ -203,9 +252,12 @@ const Notes = ({ id: idProp }) => { | |||||
> | > | ||||
<NoteLink> | <NoteLink> | ||||
<NavbarContainer> | <NavbarContainer> | ||||
<NoteLinkTitle> | |||||
New Note | |||||
</NoteLinkTitle> | |||||
<NoteLinkPrimary> | |||||
<NewIcon /> | |||||
<NoteLinkTitle> | |||||
Create Note | |||||
</NoteLinkTitle> | |||||
</NoteLinkPrimary> | |||||
</NavbarContainer> | </NavbarContainer> | ||||
</NoteLink> | </NoteLink> | ||||
</Link> | </Link> | ||||
@@ -230,11 +282,13 @@ const Notes = ({ id: idProp }) => { | |||||
> | > | ||||
<NoteLink> | <NoteLink> | ||||
<NavbarContainer> | <NavbarContainer> | ||||
<NoteLinkTitle | |||||
style={{ opacity: n.title.length > 0 ? 1 : 0.5, }} | |||||
> | |||||
{n.title.length > 0 ? n.title : '(untitled)'} | |||||
</NoteLinkTitle> | |||||
<NoteLinkPrimary> | |||||
<NoteLinkTitle | |||||
style={{ opacity: n.title.length > 0 ? 1 : 0.5, }} | |||||
> | |||||
{n.title.length > 0 ? n.title : '(untitled)'} | |||||
</NoteLinkTitle> | |||||
</NoteLinkPrimary> | |||||
{' '} | {' '} | ||||
<small> | <small> | ||||
<time | <time | ||||
@@ -248,12 +302,29 @@ const Notes = ({ id: idProp }) => { | |||||
</Link> | </Link> | ||||
<NoteActions> | <NoteActions> | ||||
<NoteAction> | <NoteAction> | ||||
<XCircle /> | |||||
<Trash2 /> | |||||
</NoteAction> | </NoteAction> | ||||
</NoteActions> | </NoteActions> | ||||
</LinkContainer> | </LinkContainer> | ||||
)) | )) | ||||
} | } | ||||
<Link | |||||
href={{ | |||||
pathname: '/bin', | |||||
}} | |||||
passHref | |||||
> | |||||
<NoteLink> | |||||
<NavbarContainer> | |||||
<NoteLinkPrimary> | |||||
<BinIcon /> | |||||
<NoteLinkTitle> | |||||
View Binned Notes | |||||
</NoteLinkTitle> | |||||
</NoteLinkPrimary> | |||||
</NavbarContainer> | |||||
</NoteLink> | |||||
</Link> | |||||
</Navbar> | </Navbar> | ||||
<Main> | <Main> | ||||
<Container> | <Container> | ||||
@@ -0,0 +1,23 @@ | |||||
export default class LocalStorage<T> { | |||||
constructor( | |||||
private readonly source: Storage, | |||||
private readonly serializer: (t: T) => string, | |||||
private readonly deserializer: (s: string) => T | |||||
) {} | |||||
getItem(id: string, fallback: T = null) { | |||||
const raw = this.source.getItem(id) | |||||
if (raw === null) { | |||||
return fallback | |||||
} | |||||
return this.deserializer(raw) | |||||
} | |||||
setItem(id: string, item: T) { | |||||
this.source.setItem(id, this.serializer(item)) | |||||
} | |||||
removeItem(id: string) { | |||||
this.source.removeItem(id) | |||||
} | |||||
} |
@@ -33,7 +33,6 @@ export const getMultiple = repository => async (query: Record<string, unknown>) | |||||
export const save = repository => (body: Partial<ModelInstance>) => async (id: string, idColumnName = 'id') => { | export const save = repository => (body: Partial<ModelInstance>) => async (id: string, idColumnName = 'id') => { | ||||
const { content: contentRaw, ...etcBody } = body | const { content: contentRaw, ...etcBody } = body | ||||
const content = contentRaw! ? JSON.stringify(contentRaw) : null | const content = contentRaw! ? JSON.stringify(contentRaw) : null | ||||
console.log('REPOSITORY', repository) | |||||
const [dao, created] = await repository.findOrCreate({ | const [dao, created] = await repository.findOrCreate({ | ||||
where: { [idColumnName]: id }, | where: { [idColumnName]: id }, | ||||
defaults: { | defaults: { | ||||
@@ -1,60 +1,82 @@ | |||||
import Note from '../../models/Note' | |||||
import Instance from '../utilities/Instance' | |||||
type NoteInstance = Instance<typeof Note.rawAttributes> | |||||
export const loadNotes = async () => { | |||||
// const localNotes = window.localStorage.getItem('notes') | |||||
const localNotes = null | |||||
let theNotes: NoteInstance[] | |||||
if (localNotes === null) { | |||||
const notes = await window.fetch('/api/notes') | |||||
theNotes = await notes.json() | |||||
window.localStorage.setItem('notes', JSON.stringify(theNotes)) | |||||
} else { | |||||
theNotes = JSON.parse(localNotes) | |||||
} | |||||
return theNotes | |||||
.map(a => ({ | |||||
...a, | |||||
updatedAt: new Date(a.updatedAt as string), | |||||
})) | |||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) | |||||
import {addTime, TimeDivision} from '../utilities/Date' | |||||
import * as Serialization from '../utilities/Serialization' | |||||
import LocalStorage from './LocalStorage' | |||||
type LoadItemParams = { | |||||
id: string, | |||||
url: string, | |||||
} | } | ||||
export const loadFolders = async () => { | |||||
// const localFolders = window.localStorage.getItem('folders') | |||||
const localFolders = null | |||||
let theFolders: unknown[] | |||||
if (localFolders === null) { | |||||
const folders = await window.fetch('/api/folders') | |||||
theFolders = await folders.json() | |||||
window.localStorage.setItem('folders', JSON.stringify(theFolders)) | |||||
} else { | |||||
theFolders = JSON.parse(localFolders) | |||||
type LoadItems = <T extends Record<string, unknown>>(params: LoadItemParams) => () => Promise<T[]> | |||||
const loadItems: LoadItems = <T extends Record<string, unknown>>(params) => async (): Promise<T[]> => { | |||||
const { id, url, } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
) | |||||
const localData = storage.getItem(id) | |||||
if (localData === null) { | |||||
const remoteItems = await window.fetch(url) | |||||
// TODO add custom serialization method | |||||
const theItems: T[] = await remoteItems.json() | |||||
storage.setItem(id, { | |||||
// TODO backend should set expiry | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: theItems, | |||||
}) | |||||
return theItems | |||||
} | |||||
const dataExpiry = new Date(Number(localData.expiry)) | |||||
const now = new Date() | |||||
if (now.getTime() > dataExpiry.getTime()) { | |||||
storage.removeItem(id) | |||||
const loader: () => Promise<T[]> = loadItems(params) | |||||
return loader() | |||||
} | } | ||||
return theFolders | |||||
return localData.items | |||||
} | } | ||||
export const saveNote = async params => { | |||||
const { | |||||
id, | |||||
title, | |||||
content, | |||||
} = params | |||||
const response = await window.fetch(`/api/notes/${id}`, { | |||||
type SaveItem = <T extends Record<string, unknown>>(p: LoadItemParams) => (item: T) => Promise<T> | |||||
const saveItem: SaveItem = <T extends Record<string, unknown>>(params) => async (item) => { | |||||
const { id: storageId, url } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
) | |||||
const localData = storage.getItem(storageId, { | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: [] | |||||
}) | |||||
console.log(localData) | |||||
const localItems = localData.items | |||||
const { id: itemId, ...theBody } = item | |||||
const theItems: T[] = ( | |||||
localItems.some(i => i.id === itemId) | |||||
? localItems.map(i => i.id === itemId ? item : i) | |||||
: [...localItems, item] | |||||
) | |||||
storage.setItem(storageId, { ...localData, items: theItems }) | |||||
const response = await window.fetch(`${url}/${itemId}`, { | |||||
method: 'put', | method: 'put', | ||||
body: JSON.stringify({ | |||||
title, | |||||
content, | |||||
}), | |||||
body: JSON.stringify(theBody), | |||||
headers: { | headers: { | ||||
'Content-Type': 'application/json', | 'Content-Type': 'application/json', | ||||
}, | }, | ||||
}) | }) | ||||
const body = await response.json() | |||||
const responseBody = await response.json() | |||||
if (response.status !== 201 && response.status !== 200) { | if (response.status !== 201 && response.status !== 200) { | ||||
throw body | |||||
throw responseBody | |||||
} | } | ||||
return body | |||||
return responseBody as T | |||||
} | } | ||||
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 saveFolder = saveItem({ id: 'folders', url: '/api/folders' }) |
@@ -14,3 +14,85 @@ export const formatDate: FormatDate = d => { | |||||
return `${year}-${month}-${date} ${hour}:${minute}` | return `${year}-${month}-${date} ${hour}:${minute}` | ||||
} | } | ||||
export enum TimeDivision { | |||||
MILLISECONDS, | |||||
SECONDS, | |||||
MINUTES, | |||||
HOURS, | |||||
DAYS | |||||
} | |||||
type GetTimeDifference = (a: Date, b: Date) => (c: TimeDivision) => number | |||||
export const getTimeDifference: GetTimeDifference = (a, b) => { | |||||
const ms = b.getTime() - a.getTime() | |||||
const absoluteMs = ms < 0 ? -ms : ms | |||||
return c => { | |||||
let divisionDifference = absoluteMs | |||||
if (c === TimeDivision.MILLISECONDS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 1000 | |||||
if (c === TimeDivision.SECONDS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 60 | |||||
if (c === TimeDivision.MINUTES) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 60 | |||||
if (c === TimeDivision.HOURS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 24 | |||||
if (c === TimeDivision.DAYS) { | |||||
return divisionDifference | |||||
} | |||||
throw new Error('Unknown time division.') | |||||
} | |||||
} | |||||
type AddTime = (refDate: Date, increment: number) => (c: TimeDivision) => Date | |||||
export const addTime: AddTime = (refDate, increment) => { | |||||
const futureDate = new Date(refDate.getTime()) | |||||
return c => { | |||||
let msIncrement = increment | |||||
if (c === TimeDivision.MILLISECONDS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 1000 | |||||
if (c === TimeDivision.SECONDS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 60 | |||||
if (c === TimeDivision.MINUTES) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 60 | |||||
if (c === TimeDivision.HOURS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 24 | |||||
if (c === TimeDivision.DAYS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
throw new Error('Unknown time division.') | |||||
} | |||||
} |
@@ -0,0 +1,3 @@ | |||||
export const serialize = JSON.stringify | |||||
export const deserialize = JSON.parse |