@@ -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<Props> = ({ | |||
defaultValue, | |||
onChange, | |||
}) => { | |||
const [hydrated, setHydrated] = React.useState(false) | |||
React.useEffect(() => { | |||
setHydrated(true) | |||
@@ -51,7 +64,10 @@ const Editor = () => { | |||
if (hydrated) { | |||
return ( | |||
<Container> | |||
<StyledContainer | |||
onChange={onChange} | |||
mobiledoc={defaultValue} | |||
> | |||
<ToolbarBase> | |||
<StyledMarkupButton | |||
tag="strong" | |||
@@ -76,13 +92,15 @@ const Editor = () => { | |||
<StyledEditor> | |||
<MobiledocEditor /> | |||
</StyledEditor> | |||
</Container> | |||
</StyledContainer> | |||
) | |||
} | |||
return ( | |||
<RawInput | |||
rows={20} | |||
defaultValue={JSON.stringify(defaultValue)} | |||
onChange={onChange} | |||
/> | |||
) | |||
} | |||
@@ -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 ( | |||
<form | |||
onSubmit={onSubmit} | |||
> | |||
<TitleContainer | |||
as="label" | |||
> | |||
<TitleInput | |||
placeholder="Title" | |||
defaultValue={defaultValues.title} | |||
/> | |||
</TitleContainer> | |||
<LeftSidebarWithMenu.ContentContainer> | |||
<Editor | |||
defaultValue={defaultValues.content} | |||
/> | |||
</LeftSidebarWithMenu.ContentContainer> | |||
</form> | |||
) | |||
} | |||
export default NoteForm |
@@ -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<Props> = ({ | |||
Select a note from the menu. | |||
</CenteredContent> | |||
)} | |||
{currentNote && ( | |||
<> | |||
<TitleContainer | |||
as="label" | |||
> | |||
<TitleInput | |||
placeholder="Title" | |||
/> | |||
</TitleContainer> | |||
<LeftSidebarWithMenu.ContentContainer> | |||
<Editor /> | |||
</LeftSidebarWithMenu.ContentContainer> | |||
</> | |||
{(currentNote as Note) && ( | |||
<NoteForm | |||
defaultValues={{ | |||
title: currentNote.title, | |||
content: currentNote.contentVersions.slice(-1)[0].content, | |||
}} | |||
/> | |||
)} | |||
</LeftSidebarWithMenu.Layout> | |||
</> | |||
@@ -4,4 +4,6 @@ export default class Folder { | |||
name: string | |||
description?: string | |||
parent?: Folder | |||
} |
@@ -7,7 +7,7 @@ export default class Note { | |||
title: string | |||
folder?: Folder | |||
folder: Folder | |||
authorUser: User | |||
@@ -1,9 +1,7 @@ | |||
import Note from './Note' | |||
export default class NoteVersion { | |||
id: string | |||
content: string | |||
content: any | |||
createdAt: Date | string | |||
@@ -0,0 +1,44 @@ | |||
import Note from '../../models/Note' | |||
import Folder from '../../models/Folder' | |||
export interface Plugin { | |||
type: string | |||
} | |||
export interface Paginated<T> extends Array<T> { | |||
totalLength: number, | |||
} | |||
export type PaginationOptions = { | |||
skip?: number, | |||
take?: number, | |||
} | |||
export interface StoragePlugin extends Plugin { | |||
type: 'storage' | |||
saveNote(newNote: Note): Promise<Note> | |||
loadNote(noteId: string): Promise<Note> | |||
loadAllNotes(options?: PaginationOptions): Promise<Paginated<Note>> | |||
saveFolder(newFolder: Folder): Promise<Folder> | |||
loadFolder(folderId: string): Promise<Folder> | |||
} | |||
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() | |||
} | |||
} |
@@ -30,6 +30,7 @@ export default class MyDocument extends Document { | |||
{initialProps.styles} | |||
<link rel="preconnect" href="https://fonts.gstatic.com" /> | |||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Encode+Sans:wdth,wght@75..112.5,100..900&display=swap" /> | |||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bitter:ital,wght@0,100..900;1,100..900&display=swap" /> | |||
<link rel="stylesheet" href={`${publicUrl}/global.css`} /> | |||
<link rel="stylesheet" href={`${publicUrl}/theme.css`} /> | |||
<link rel="stylesheet" title="Dark" href={`${publicUrl}/theme/dark.css`} /> | |||
@@ -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, | |||
}, | |||
} | |||
@@ -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, | |||
}, | |||
} | |||
} |
@@ -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<Note> | |||
} | |||
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<Note> | |||
} | |||
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 | |||
} | |||
} |