From 685731bb89098c29627ff8c42cdebffb597603e6 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 3 May 2021 18:59:09 +0800 Subject: [PATCH] Implement plugins Implement storage plugins for local and remote. --- src/components/molecules/Editor/index.tsx | 24 +- .../organisms/forms/NoteForm/index.tsx | 56 ++++ src/components/templates/NoteView/index.tsx | 44 +-- src/models/Folder.ts | 2 + src/models/Note.ts | 2 +- src/models/NoteVersion.ts | 4 +- src/modules/Plugin/controller.ts | 0 src/modules/Plugin/service.ts | 44 +++ src/pages/_document.tsx | 1 + src/pages/my/notes/[noteId].tsx | 42 ++- src/pages/my/notes/index.tsx | 23 +- src/plugins/storage/localStorage.ts | 255 ++++++++++++++++++ src/utils/getFormValues.ts | 0 13 files changed, 434 insertions(+), 63 deletions(-) create mode 100644 src/components/organisms/forms/NoteForm/index.tsx create mode 100644 src/modules/Plugin/controller.ts create mode 100644 src/modules/Plugin/service.ts create mode 100644 src/plugins/storage/localStorage.ts create mode 100644 src/utils/getFormValues.ts diff --git a/src/components/molecules/Editor/index.tsx b/src/components/molecules/Editor/index.tsx index 0543b75..46ab7bb 100644 --- a/src/components/molecules/Editor/index.tsx +++ b/src/components/molecules/Editor/index.tsx @@ -3,6 +3,11 @@ import * as T from '@tesseract-design/react-common' import {Container, Editor as MobiledocEditor, MarkupButton, LinkButton} from 'react-mobiledoc-editor' import styled, {CSSObject} from 'styled-components' +const StyledContainer = styled(Container)({ + fontFamily: 'Bitter, serif', + fontSize: '1.25rem', +}) + const TOOLBAR_BUTTON_COMMON_STYLES: CSSObject = { border: 0, backgroundColor: 'transparent', @@ -43,7 +48,15 @@ const StyledEditor = styled('div')({ }, }) -const Editor = () => { +type Props = { + defaultValue: unknown, + onChange?: (...args: unknown[]) => unknown, +} + +const Editor: React.FC = ({ + defaultValue, + onChange, +}) => { const [hydrated, setHydrated] = React.useState(false) React.useEffect(() => { setHydrated(true) @@ -51,7 +64,10 @@ const Editor = () => { if (hydrated) { return ( - + { - + ) } return ( ) } diff --git a/src/components/organisms/forms/NoteForm/index.tsx b/src/components/organisms/forms/NoteForm/index.tsx new file mode 100644 index 0000000..96c710a --- /dev/null +++ b/src/components/organisms/forms/NoteForm/index.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import styled from 'styled-components' +import {LeftSidebarWithMenu} from '@tesseract-design/viewfinder' +import Editor from '../../../molecules/Editor' + +const TitleContainer = styled(LeftSidebarWithMenu.ContentContainer)({ + display: 'block', +}) + +// @ts-ignore +const TitleInput = styled('input')({ + color: 'inherit', + font: 'inherit', + fontSize: '3rem', + padding: 0, + border: 0, + backgroundColor: 'transparent', + fontFamily: 'var(--font-family-headings), sans-serif', + fontStretch: 'var(--font-stretch-headings, normal)', + fontWeight: 'var(--font-weight-headings, 400)', + height: '4rem', + outline: 0, + width: '100%', + display: 'block', + marginBottom: '1rem', +}) + +const NoteForm = ({ + defaultValues = { + title: '', + content: {}, + }, + onSubmit = null, +}) => { + return ( +
+ + + + + + +
+ ) +} + +export default NoteForm diff --git a/src/components/templates/NoteView/index.tsx b/src/components/templates/NoteView/index.tsx index ccf672f..387170c 100644 --- a/src/components/templates/NoteView/index.tsx +++ b/src/components/templates/NoteView/index.tsx @@ -10,7 +10,7 @@ import Brand from '../../molecules/Brand' import NoteLinkContent from '../../molecules/NoteLinkContent' import Folder from '../../../models/Folder' import FolderLinkContent from '../../molecules/FolderLinkContent' -import Editor from '../../molecules/Editor' +import NoteForm from '../../organisms/forms/NoteForm' const SidebarLink = styled(Link)({ textDecoration: 'none', @@ -26,33 +26,11 @@ const CenteredContent = styled(LeftSidebarWithMenu.ContentContainer)({ }, }) -// @ts-ignore -const TitleInput = styled('input')({ - color: 'inherit', - font: 'inherit', - fontSize: '3rem', - padding: 0, - border: 0, - backgroundColor: 'transparent', - fontFamily: 'var(--font-family-headings), sans-serif', - fontStretch: 'var(--font-stretch-headings, normal)', - fontWeight: 'var(--font-weight-headings, 400)', - height: '4rem', - outline: 0, - width: '100%', - display: 'block', - marginBottom: '1rem', -}) - const SidebarTitle = styled('h1')({ margin: 0, lineHeight: '4rem', }) -const TitleContainer = styled(LeftSidebarWithMenu.ContentContainer)({ - display: 'block', -}) - const SidebarSubtitle = styled('p')({ margin: '2rem 0', }) @@ -210,19 +188,13 @@ const NoteView: React.FC = ({ Select a note from the menu. )} - {currentNote && ( - <> - - - - - - - + {(currentNote as Note) && ( + )} diff --git a/src/models/Folder.ts b/src/models/Folder.ts index c3e9fa5..1461687 100644 --- a/src/models/Folder.ts +++ b/src/models/Folder.ts @@ -4,4 +4,6 @@ export default class Folder { name: string description?: string + + parent?: Folder } diff --git a/src/models/Note.ts b/src/models/Note.ts index 3b1e92c..3130fd0 100644 --- a/src/models/Note.ts +++ b/src/models/Note.ts @@ -7,7 +7,7 @@ export default class Note { title: string - folder?: Folder + folder: Folder authorUser: User diff --git a/src/models/NoteVersion.ts b/src/models/NoteVersion.ts index cce5d1e..e6e4908 100644 --- a/src/models/NoteVersion.ts +++ b/src/models/NoteVersion.ts @@ -1,9 +1,7 @@ -import Note from './Note' - export default class NoteVersion { id: string - content: string + content: any createdAt: Date | string diff --git a/src/modules/Plugin/controller.ts b/src/modules/Plugin/controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/Plugin/service.ts b/src/modules/Plugin/service.ts new file mode 100644 index 0000000..ac868e6 --- /dev/null +++ b/src/modules/Plugin/service.ts @@ -0,0 +1,44 @@ +import Note from '../../models/Note' +import Folder from '../../models/Folder' + +export interface Plugin { + type: string +} + +export interface Paginated extends Array { + totalLength: number, +} + +export type PaginationOptions = { + skip?: number, + take?: number, +} + +export interface StoragePlugin extends Plugin { + type: 'storage' + saveNote(newNote: Note): Promise + loadNote(noteId: string): Promise + loadAllNotes(options?: PaginationOptions): Promise> + saveFolder(newFolder: Folder): Promise + loadFolder(folderId: string): Promise +} + +export type StoragePluginOptions = { + +} + +export abstract class StoragePluginBase { + readonly type = 'storage' as const +} + +export default class PluginService { + private readonly registered: Plugin[] = [] + + register(plugin: Plugin) { + this.registered.push(plugin) + } + + getAll() { + return this.registered.slice() + } +} diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index d6c721d..845d360 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -30,6 +30,7 @@ export default class MyDocument extends Document { {initialProps.styles} + diff --git a/src/pages/my/notes/[noteId].tsx b/src/pages/my/notes/[noteId].tsx index 2819580..1ba7c41 100644 --- a/src/pages/my/notes/[noteId].tsx +++ b/src/pages/my/notes/[noteId].tsx @@ -34,6 +34,11 @@ export default Page export const getServerSideProps: GetServerSideProps = async (ctx) => { const { [QueryFragment.SUBPAGE]: subpage = '', noteId, } = ctx.query + const currentFolder: Folder = { + id: '0', + name: 'Root Folder', + description: 'Default location of your notes.', + } const authorUser = { id: '0', profile: { @@ -51,34 +56,45 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { contentVersions: [ { id: '0', - content: 'Note content', + content: { + version: '0.3.2', + markups: [ + ['b'], + ['i'], + ], + atoms: [], + cards: [], + sections: [ + [1, "p", [ + [0, [], 0, "Example with no markup"], + [0, [0], 1, "Example wrapped in b tag (opened markup #0), 1 closed markup"], + [0, [1], 0, "Example opening i tag (opened markup with #1, 0 closed markups)"], + [0, [], 1, "Example closing i tag (no opened markups, 1 closed markup)"], + [0, [1, 0], 1, "Example opening i tag and b tag, closing b tag (opened markups #1 and #0, 1 closed markup [closes markup #0])"], + [0, [], 1, "Example closing i tag, (no opened markups, 1 closed markup [closes markup #1])"], + ]] + ], + }, createdAt: new Date().toISOString(), - } + }, ], + folder: currentFolder, } ] + const currentNote: Note = notes.find(n => n.id === noteId) const subfolders: Folder[] = [ { - id: '0', + id: '1', name: 'Child Folder', description: 'Where we put other notes', } ] - const currentNote: Note = { - id: noteId as string, - title: 'This Note', - authorUser, - contentVersions: [], - } return { props: { subpage, notes, subfolders, - currentFolder: { - name: 'Root Folder', - description: 'Default location of your notes.', - }, + currentFolder, currentNote, }, } diff --git a/src/pages/my/notes/index.tsx b/src/pages/my/notes/index.tsx index 9dc1855..625e6d4 100644 --- a/src/pages/my/notes/index.tsx +++ b/src/pages/my/notes/index.tsx @@ -31,6 +31,11 @@ export default Page export const getServerSideProps: GetServerSideProps = async (ctx) => { const { [QueryFragment.SUBPAGE]: subpage = '' } = ctx.query + const currentFolder: Folder = { + id: '0', + name: 'Root Folder', + description: 'Default location of your notes.', + } const authorUser = { id: '0', profile: { @@ -48,15 +53,22 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { contentVersions: [ { id: '0', - content: 'Note content', + content: { + version: '0.3.2', + markups: [], + atoms: [], + cards: [], + sections: [], + }, createdAt: new Date().toISOString(), - } + }, ], + folder: currentFolder, } ] const subfolders: Folder[] = [ { - id: '0', + id: '1', name: 'Child Folder', description: 'Where we put other notes', } @@ -66,10 +78,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { subpage, notes, subfolders, - currentFolder: { - name: 'Root Folder', - description: 'Default location of your notes.', - }, + currentFolder, }, } } diff --git a/src/plugins/storage/localStorage.ts b/src/plugins/storage/localStorage.ts new file mode 100644 index 0000000..bd3300f --- /dev/null +++ b/src/plugins/storage/localStorage.ts @@ -0,0 +1,255 @@ +import Note from '../../models/Note' +import Folder from '../../models/Folder' +import { + Paginated, + PaginationOptions, + StoragePluginBase, + StoragePlugin, + StoragePluginOptions, +} from '../../modules/Plugin/service' + +interface LocalStoragePluginOptions extends StoragePluginOptions { + noteStorageId?: string, + folderStorageId?: string, + engine?: Storage, +} + +class LocalStoragePlugin extends StoragePluginBase implements StoragePlugin { + private readonly engine: Storage + private readonly noteStorageId: string + private readonly folderStorageId: string + private noteCache: Note[] + private folderCache: Folder[] + private readonly serialize = JSON.stringify + private readonly deserialize = JSON.parse + + constructor({ + engine = localStorage, + noteStorageId = 'zeichen.notes', + folderStorageId = 'zeichen.folders', + }: LocalStoragePluginOptions) { + super() + this.engine = engine + this.noteStorageId = noteStorageId + this.folderStorageId = folderStorageId + } + + private async syncFolders() { + if (!this.folderCache) { + const foldersSerialized: string = await this.engine.getItem(this.folderStorageId) || '[]' + const folders: Folder[] = this.deserialize(foldersSerialized) + folders.forEach((folder, _, thisCollection) => { + if (folder.parent) { + for (let i = 0; i < thisCollection.length; i += 1) { + if (thisCollection[i].id !== folder.parent.id) { + continue + } + folder.parent = thisCollection[i] + } + } + }) + this.folderCache = folders + } + } + + private async syncNotes() { + if (!this.noteCache) { + await this.syncFolders() + const notesSerialized: string = await this.engine.getItem(this.noteStorageId) || '[]' + const notesRaw: Note[] = this.deserialize(notesSerialized) + this.noteCache = notesRaw.map(note => ({ + ...note, + folder: this.folderCache.find(f => f.id === note.folder.id) + })) + } + } + + async saveNote(newNote: Note) { + await this.syncNotes() + const replacementNote = { + ...newNote, + folder: this.folderCache.find(f => f.id === newNote.folder.id) + } + const targetNote = this.noteCache.some(oldNote => oldNote.id === newNote.id) + const newNotes = ( + targetNote + ? this.noteCache.map(oldNote => ( + oldNote.id === newNote.id + ? replacementNote + : oldNote + )) + : [...this.noteCache, replacementNote] + ) + await this.engine.setItem(this.noteStorageId, this.serialize(newNotes)) + this.noteCache = newNotes + return replacementNote + } + + async loadNote(noteId: string) { + await this.syncNotes() + return this.noteCache.find(cachedNote => cachedNote.id === noteId) + } + + async loadAllNotes(options = {} as PaginationOptions) { + await this.syncNotes() + const { skip, take } = options + const data = { + ...this.noteCache.slice(skip, skip + take), + totalLength: this.noteCache.length, + } + return data as Paginated + } + + async loadFolder(folderId: string) { + await this.syncFolders() + return this.folderCache.find(cachedFolder => cachedFolder.id === folderId) + } + + async saveFolder(newFolder: Folder) { + await this.syncFolders() + const replacementFolder = { + ...newFolder, + parent: this.folderCache.find(f => f.id === newFolder.parent.id) + } + const targetFolder = this.folderCache.some(oldFolder => oldFolder.id === newFolder.id) + const newFolders = ( + targetFolder + ? this.folderCache.map(oldFolder => ( + oldFolder.id === newFolder.id + ? replacementFolder + : oldFolder + )) + : [...this.folderCache, replacementFolder] + ) + const newFoldersSerializeObject = newFolders.map(f => ({ + ...f, + parent: { id: f.parent.id, }, + })) + await this.engine.setItem(this.folderStorageId, this.serialize(newFoldersSerializeObject)) + this.folderCache = newFolders + return replacementFolder + } +} + +interface RemoteStoragePluginOptions extends StoragePluginOptions { + baseUrl: string, +} + +class RemoteStoragePlugin extends StoragePluginBase implements StoragePlugin { + private readonly baseUrl: string + private noteCache: Note[] + private folderCache: Folder[] + + constructor({ + baseUrl, + }: RemoteStoragePluginOptions) { + super() + this.baseUrl = baseUrl + } + + private async syncFolders() { + if (!this.folderCache) { + const url = new URL('/folders', this.baseUrl) + const response = await fetch(url.toString()) + const folders: Folder[] = await response.json() + folders.forEach((folder, _, thisCollection) => { + if (folder.parent) { + for (let i = 0; i < thisCollection.length; i += 1) { + if (thisCollection[i].id !== folder.parent.id) { + continue + } + folder.parent = thisCollection[i] + } + } + }) + this.folderCache = folders + } + } + + private async syncNotes() { + if (!this.noteCache) { + await this.syncFolders() + const url = new URL('/notes', this.baseUrl) + const response = await fetch(url.toString()) + const notesRaw: Note[] = await response.json() + this.noteCache = notesRaw.map(note => ({ + ...note, + folder: this.folderCache.find(f => f.id === note.folder.id) + })) + } + } + + async loadNote(noteId: string) { + await this.syncNotes() + return this.noteCache.find(cachedNote => cachedNote.id === noteId) + } + + async loadAllNotes(options = {} as PaginationOptions) { + await this.syncNotes() + const { skip, take } = options + const data = { + ...this.noteCache.slice(skip, skip + take), + totalLength: this.noteCache.length, + } + return data as Paginated + } + + async loadFolder(folderId: string) { + await this.syncFolders() + return this.folderCache.find(cachedFolder => cachedFolder.id === folderId) + } + + async saveNote(newNote: Note) { + await this.syncNotes() + const relativeUrl: string = Boolean(newNote.id) ? `/notes/${newNote.id}` : '/notes' + const method = Boolean(newNote.id) ? 'PUT' : 'POST' + const url = new URL(relativeUrl, this.baseUrl) + const response = await fetch(url.toString(), { + method, + body: JSON.stringify(newNote), + }) + const retrieved: Note = await response.json() + const replacementNote: Note = { + ...retrieved, + folder: this.folderCache.find(f => f.id === newNote.folder.id) + } + const targetNote = this.noteCache.some(oldNote => oldNote.id === newNote.id) + this.noteCache = ( + targetNote + ? this.noteCache.map(oldNote => ( + oldNote.id === newNote.id + ? replacementNote + : oldNote + )) + : [...this.noteCache, replacementNote] + ) + return replacementNote + } + + async saveFolder(newFolder: Folder) { + await this.syncFolders() + const relativeUrl: string = Boolean(newFolder.id) ? `/folders/${newFolder.id}` : '/notes' + const method = Boolean(newFolder.id) ? 'PUT' : 'POST' + const url = new URL(relativeUrl, this.baseUrl) + const response = await fetch(url.toString(), { + method, + body: JSON.stringify(newFolder), + }) + const retrieved: Folder = await response.json() + const replacementFolder: Folder = { + ...retrieved, + parent: this.folderCache.find(f => f.id === newFolder.parent.id) + } + const targetFolder = this.folderCache.some(oldFolder => oldFolder.id === newFolder.id) + this.folderCache = ( + targetFolder + ? this.folderCache.map(oldFolder => ( + oldFolder.id === newFolder.id + ? replacementFolder + : oldFolder + )) + : [...this.folderCache, replacementFolder] + ) + return replacementFolder + } +} diff --git a/src/utils/getFormValues.ts b/src/utils/getFormValues.ts new file mode 100644 index 0000000..e69de29