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 { formatDate } from '../utilities/Date' | |||
import { useRouter } from 'next/router' | |||
import { XCircle } from 'react-feather' | |||
import { Trash2, FilePlus, FolderPlus } from 'react-feather' | |||
const Navbar = styled('aside')({ | |||
width: 360, | |||
@@ -71,10 +71,14 @@ const NoteLink = styled('a')({ | |||
position: 'relative', | |||
}) | |||
const NoteLinkTitle = styled('strong')({ | |||
const NoteLinkPrimary = styled('div')({ | |||
display: 'block', | |||
}) | |||
const NoteLinkTitle = styled('strong')({ | |||
verticalAlign: 'middle', | |||
}) | |||
const LinkContainer = styled('div')({ | |||
position: 'relative', | |||
}) | |||
@@ -108,6 +112,21 @@ const NoteLinkBackground = styled('span')({ | |||
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, } | |||
const Notes = ({ id: idProp }) => { | |||
@@ -126,7 +145,6 @@ const Notes = ({ id: idProp }) => { | |||
timeoutRef.current = window.setTimeout(async () => { | |||
const newNote = await Storage.saveNote({ | |||
...stateRef.current, | |||
title, | |||
updatedAt: new Date().toISOString(), | |||
}) | |||
if (router.query.id !== id) { | |||
@@ -151,6 +169,7 @@ const Notes = ({ id: idProp }) => { | |||
} | |||
const handleTitleChange = e => { | |||
stateRef.current.title = e.target.value | |||
setTitle(e.target.value) | |||
autoSave() | |||
} | |||
@@ -184,10 +203,6 @@ const Notes = ({ id: idProp }) => { | |||
setId(idProp || generateId()) | |||
}, [idProp]) | |||
React.useEffect(() => { | |||
autoSave() | |||
}, [title]) | |||
return ( | |||
<React.Fragment> | |||
<Head> | |||
@@ -195,6 +210,40 @@ const Notes = ({ id: idProp }) => { | |||
<link rel="icon" href="/favicon.ico" /> | |||
</Head> | |||
<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 | |||
href={{ | |||
pathname: '/notes', | |||
@@ -203,9 +252,12 @@ const Notes = ({ id: idProp }) => { | |||
> | |||
<NoteLink> | |||
<NavbarContainer> | |||
<NoteLinkTitle> | |||
New Note | |||
</NoteLinkTitle> | |||
<NoteLinkPrimary> | |||
<NewIcon /> | |||
<NoteLinkTitle> | |||
Create Note | |||
</NoteLinkTitle> | |||
</NoteLinkPrimary> | |||
</NavbarContainer> | |||
</NoteLink> | |||
</Link> | |||
@@ -230,11 +282,13 @@ const Notes = ({ id: idProp }) => { | |||
> | |||
<NoteLink> | |||
<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> | |||
<time | |||
@@ -248,12 +302,29 @@ const Notes = ({ id: idProp }) => { | |||
</Link> | |||
<NoteActions> | |||
<NoteAction> | |||
<XCircle /> | |||
<Trash2 /> | |||
</NoteAction> | |||
</NoteActions> | |||
</LinkContainer> | |||
)) | |||
} | |||
<Link | |||
href={{ | |||
pathname: '/bin', | |||
}} | |||
passHref | |||
> | |||
<NoteLink> | |||
<NavbarContainer> | |||
<NoteLinkPrimary> | |||
<BinIcon /> | |||
<NoteLinkTitle> | |||
View Binned Notes | |||
</NoteLinkTitle> | |||
</NoteLinkPrimary> | |||
</NavbarContainer> | |||
</NoteLink> | |||
</Link> | |||
</Navbar> | |||
<Main> | |||
<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') => { | |||
const { content: contentRaw, ...etcBody } = body | |||
const content = contentRaw! ? JSON.stringify(contentRaw) : null | |||
console.log('REPOSITORY', repository) | |||
const [dao, created] = await repository.findOrCreate({ | |||
where: { [idColumnName]: id }, | |||
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', | |||
body: JSON.stringify({ | |||
title, | |||
content, | |||
}), | |||
body: JSON.stringify(theBody), | |||
headers: { | |||
'Content-Type': 'application/json', | |||
}, | |||
}) | |||
const body = await response.json() | |||
const responseBody = await response.json() | |||
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}` | |||
} | |||
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 |