Browse Source

Implement autosave, basic saving and updates.

feature/transactions
TheoryOfNekomata 3 years ago
parent
commit
65531d8393
39 changed files with 2545 additions and 252 deletions
  1. +9
    -0
      .babelrc
  2. +11
    -0
      .editorconfig
  3. +5
    -0
      .gitignore
  4. +9
    -0
      .prettierrc
  5. +7
    -0
      .sequelizerc
  6. +18
    -0
      config/config.js
  7. +15
    -0
      migrate.ts
  8. +100
    -0
      models/Folder.ts
  9. +99
    -0
      models/Note.ts
  10. +43
    -0
      models/Tag.ts
  11. +4
    -0
      next-env.d.ts
  12. +15
    -0
      next.config.js
  13. +21
    -2
      package.json
  14. +0
    -7
      pages/_app.js
  15. +0
    -6
      pages/api/hello.js
  16. +0
    -65
      pages/index.js
  17. +147
    -0
      src/components/Editor/Editor.tsx
  18. +9
    -0
      src/models.ts
  19. +8
    -0
      src/pages/_app.tsx
  20. +32
    -0
      src/pages/_document.tsx
  21. +5
    -0
      src/pages/api/folders.ts
  22. +5
    -0
      src/pages/api/folders/[id].ts
  23. +5
    -0
      src/pages/api/notes.ts
  24. +5
    -0
      src/pages/api/notes/[id].ts
  25. +299
    -0
      src/pages/notes.tsx
  26. +4
    -0
      src/pages/notes/[id].tsx
  27. +60
    -0
      src/services/Controller.ts
  28. +46
    -0
      src/services/Folder.ts
  29. +69
    -0
      src/services/Note.ts
  30. +60
    -0
      src/services/Storage.ts
  31. +16
    -0
      src/utilities/Date.ts
  32. +5
    -0
      src/utilities/Id.ts
  33. +5
    -0
      src/utilities/Instance.ts
  34. +46
    -0
      src/utilities/ORM.ts
  35. +45
    -0
      src/utilities/Response.ts
  36. +0
    -122
      styles/Home.module.css
  37. +0
    -16
      styles/globals.css
  38. +35
    -0
      tsconfig.json
  39. +1283
    -34
      yarn.lock

+ 9
- 0
.babelrc View File

@@ -0,0 +1,9 @@
{
"presets": ["next/babel"],
"plugins": [
"babel-plugin-transform-typescript-metadata",
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", { "loose": true }],
"babel-plugin-parameter-decorator"
]
}

+ 11
- 0
.editorconfig View File

@@ -0,0 +1,11 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 8
trim_trailing_whitespace = true

+ 5
- 0
.gitignore View File

@@ -25,6 +25,7 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env
.env.local
.env.development.local
.env.test.local
@@ -32,3 +33,7 @@ yarn-error.log*

# vercel
.vercel

database/
.bin/
.idea/

+ 9
- 0
.prettierrc View File

@@ -0,0 +1,9 @@
{
"jsxSingleQuote": false,
"singleQuote": true,
"printWidth": 120,
"semi": false,
"trailingComma": "all",
"quoteProps": "as-needed",
"arrowParens": "always"
}

+ 7
- 0
.sequelizerc View File

@@ -0,0 +1,7 @@
const path = require('path');

module.exports = {
'config-path': path.resolve('database', 'config', 'config.js'),
'seeders-path': path.resolve('database', 'seeders'),
'migrations-path': path.resolve('database', 'migrations')
};

+ 18
- 0
config/config.js View File

@@ -0,0 +1,18 @@
const dotenv = require('dotenv')

dotenv.config()

module.exports = {
"development": {
"host": process.env.DATABASE_URL,
"dialect": process.env.DATABASE_DIALECT
},
"test": {
"host": process.env.DATABASE_URL,
"dialect": process.env.DATABASE_DIALECT
},
"production": {
"host": process.env.DATABASE_URL,
"dialect": process.env.DATABASE_DIALECT
}
}

+ 15
- 0
migrate.ts View File

@@ -0,0 +1,15 @@
import models from './src/models'

export const up = async queryInterface => {
models.forEach(m => {
queryInterface.createTable(m.tableName, m.rawAttributes)
})
}

export const down = async (queryInterface) => {
models
.reduce((reverse, m) => [m, ...reverse], [])
.forEach(m => {
queryInterface.dropTable(m.tableName)
})
}

+ 100
- 0
models/Folder.ts View File

@@ -0,0 +1,100 @@
import * as DataType from 'sequelize'

export default {
tableName: 'folders',
modelName: 'Folder',
options: {
timestamps: true,
paranoid: true,
createdAt: 'createdAt',
updatedAt: 'updatedAt',
deletedAt: 'deletedAt',
},
rawAttributes: {
id: {
allowNull: true,
primaryKey: true,
type: DataType.UUIDV4,
},
name: {
allowNull: false,
type: DataType.STRING,
},
parentId: {
allowNull: true,
type: DataType.UUIDV4,
},
createdAt: {
allowNull: false,
type: DataType.DATE,
},
updatedAt: {
allowNull: false,
type: DataType.DATE,
},
deletedAt: {
allowNull: true,
type: DataType.DATE,
},
}
}

// import 'reflect-metadata'
// import {
// AllowNull,
// BelongsTo,
// Column,
// CreatedAt,
// DeletedAt,
// ForeignKey,
// HasMany,
// Model,
// PrimaryKey,
// Table,
// UpdatedAt,
// DataType,
// } from 'sequelize-typescript'
// import Model from './Model'
//
// @Table({
// timestamps: true,
// paranoid: true,
// })
//
// export default class Folder extends Model<Folder> {
// @AllowNull
// @PrimaryKey
// @Column(DataType.UUIDV4)
// id?: string
//
// @Column
// name: string
//
// @AllowNull
// @ForeignKey(() => Folder)
// @Column(DataType.UUIDV4)
// parentId?: string
//
// @BelongsTo(() => Folder, 'parentId')
// parent?: Folder
//
// @HasMany(() => Folder, 'parentId')
// children: Folder[]
//
// @Column(DataType.DATE)
// @CreatedAt
// createdAt: Date
//
// @Column(DataType.DATE)
// @UpdatedAt
// updatedAt: Date
//
// @AllowNull
// @Column(DataType.DATE)
// @DeletedAt
// deletedAt?: Date
//
// @HasMany(() => Model, 'folderId')
// notes: Model[]
// }
//

+ 99
- 0
models/Note.ts View File

@@ -0,0 +1,99 @@
import * as DataType from 'sequelize'

export default {
tableName: 'notes',
modelName: 'Note',
options: {
timestamps: true,
paranoid: true,
createdAt: 'createdAt',
updatedAt: 'updatedAt',
deletedAt: 'deletedAt',
},
rawAttributes: {
id: {
allowNull: true,
primaryKey: true,
type: DataType.UUIDV4,
},
title: {
allowNull: false,
type: DataType.STRING,
},
content: {
allowNull: true,
type: DataType.TEXT({ length: 'long', }),
},
folderId: {
allowNull: true,
type: DataType.UUIDV4,
},
createdAt: {
allowNull: false,
type: DataType.DATE,
},
updatedAt: {
allowNull: false,
type: DataType.DATE,
},
deletedAt: {
allowNull: true,
type: DataType.DATE,
},
},
}

// import 'reflect-metadata'
// import {
// AllowNull,
// BelongsTo,
// Column,
// CreatedAt,
// DeletedAt,
// ForeignKey,
// Model,
// PrimaryKey,
// Table,
// UpdatedAt,
// DataType,
// } from 'sequelize-typescript'
// import Folder from './Folder'
//
// @Table({
// timestamps: true,
// paranoid: true,
// })
// export default class Model extends Model<Model> {
// @AllowNull
// @PrimaryKey
// @Column(DataType.UUIDV4)
// id?: string
//
// @Column
// title: string
//
// @AllowNull
// @Column(DataType.TEXT({ length: 'long' }))
// content?: string
//
// @AllowNull
// @ForeignKey(() => Folder)
// @Column(DataType.UUIDV4)
// folderId?: string
//
// @BelongsTo(() => Folder, 'folderId')
// folder?: Folder
//
// @Column(DataType.DATE)
// @CreatedAt
// createdAt: Date
//
// @Column(DataType.DATE)
// @UpdatedAt
// updatedAt: Date
//
// @AllowNull
// @Column(DataType.DATE)
// @DeletedAt
// deletedAt?: Date
// }

+ 43
- 0
models/Tag.ts View File

@@ -0,0 +1,43 @@
import * as DataType from 'sequelize'

export default {
options: {
timestamps: false,
},
modelName: 'Tag',
tableName: 'tags',
rawAttributes: {
id: {
allowNull: true,
primaryKey: true,
type: DataType.UUIDV4,
},
name: {
allowNull: false,
type: DataType.STRING,
},
}
}

// import 'reflect-metadata'
// import {
// AllowNull,
// Column,
// Model,
// PrimaryKey,
// Table,
// DataType,
// } from 'sequelize-typescript'
//
// @Table({
// timestamps: false,
// })
// export default class Tag extends Model<Tag> {
// @AllowNull
// @PrimaryKey
// @Column(DataType.UUIDV4)
// id?: string
//
// @Column
// name: string
// }

+ 4
- 0
next-env.d.ts View File

@@ -0,0 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

declare module 'react-mobiledoc-editor'

+ 15
- 0
next.config.js View File

@@ -0,0 +1,15 @@
module.exports = {
poweredByHeader: false,
devIndicators: {
autoPrerender: false,
},
async redirects() {
return [
{
source: '/',
destination: '/notes',
permanent: true,
},
]
},
}

+ 21
- 2
package.json View File

@@ -5,11 +5,30 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"migrate": "tsc migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate"
},
"dependencies": {
"dotenv": "^8.2.0",
"mobiledoc-kit": "^0.13.1",
"next": "9.5.5",
"react": "17.0.1",
"react-dom": "17.0.1"
"react-dom": "17.0.1",
"react-feather": "^2.0.8",
"react-mobiledoc-editor": "^0.10.0",
"sequelize": "5.22.0",
"sequelize-cli": "^6.2.0",
"sequelize-typescript": "^1.1.0",
"styled-components": "^5.2.0",
"uuid": "^8.3.1"
},
"devDependencies": {
"@types/node": "^14.14.2",
"@types/react": "^16.9.53",
"@types/uuid": "^8.3.0",
"typescript": "^4.0.3"
},
"optionalDependencies": {
"sqlite3": "^5.0.0"
}
}

+ 0
- 7
pages/_app.js View File

@@ -1,7 +0,0 @@
import '../styles/globals.css'

function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}

export default MyApp

+ 0
- 6
pages/api/hello.js View File

@@ -1,6 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction

export default (req, res) => {
res.statusCode = 200
res.json({ name: 'John Doe' })
}

+ 0
- 65
pages/index.js View File

@@ -1,65 +0,0 @@
import Head from 'next/head'
import styles from '../styles/Home.module.css'

export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>

<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>

<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.js</code>
</p>

<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h3>Documentation &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>

<a href="https://nextjs.org/learn" className={styles.card}>
<h3>Learn &rarr;</h3>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>

<a
href="https://github.com/vercel/next.js/tree/master/examples"
className={styles.card}
>
<h3>Examples &rarr;</h3>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>

<a
href="https://vercel.com/import?filter=next.js&utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h3>Deploy &rarr;</h3>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
</main>

<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
</a>
</footer>
</div>
)
}

+ 147
- 0
src/components/Editor/Editor.tsx View File

@@ -0,0 +1,147 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import {
Container,
MarkupButton,
LinkButton,
SectionSelect,
AttributeSelect,
SectionButton,
Editor as MobileDocEditor,
} from 'react-mobiledoc-editor'
import { Bold, Italic, Code, List, Link, MessageCircle, } from 'react-feather'
import styled from 'styled-components'

const ToolbarWrapper = styled('div')({
display: 'flex',
gap: '1rem',
})

const ToolbarItem = styled('div')({
display: 'block',
})

const StyledMarkupButton = styled(MarkupButton)({
border: 0,
background: 'transparent',
width: '2rem',
height: '2rem',
font: 'inherit',
padding: 0,
outline: 0,
})

const StyledLinkButton = styled(LinkButton)({
border: 0,
background: 'transparent',
width: '2rem',
height: '2rem',
font: 'inherit',
padding: 0,
outline: 0,
})

const StyledSectionButton = styled(SectionButton)({
border: 0,
background: 'transparent',
width: '2rem',
height: '2rem',
font: 'inherit',
fontFamily: 'monospace',
fontSize: '1.75rem',
lineHeight: 0,
padding: 0,
outline: 0,
})

const propTypes = {
onChange: PropTypes.func,
cards: PropTypes.array,
atoms: PropTypes.array,
content: PropTypes.object,
placeholder: PropTypes.string,
autoFocus: PropTypes.bool,
}

type Props = PropTypes.InferProps<typeof propTypes>

const Editor: React.FC<Props> = ({
onChange,
cards = [],
atoms = [],
content,
placeholder,
autoFocus,
}) => {
const [isClient, setIsClient, ] = React.useState(false)
const editorRef = React.useRef(null)

const handleInitialize = e => {
editorRef.current = e
}

React.useEffect(() => {
setIsClient(true)
}, [])

if (isClient) {
return (
<Container
autofocus={autoFocus}
onChange={onChange}
cards={[
...cards,
]}
atoms={[
...atoms,
]}
mobiledoc={content}
placeholder={placeholder}
didCreateEditor={handleInitialize}
>
<ToolbarWrapper>
<ToolbarItem>
<StyledMarkupButton tag="strong">
<Bold />
</StyledMarkupButton>
</ToolbarItem>
<ToolbarItem>
<StyledMarkupButton tag="em">
<Italic />
</StyledMarkupButton>
</ToolbarItem>
<ToolbarItem>
<StyledMarkupButton tag="code">
<Code />
</StyledMarkupButton>
</ToolbarItem>
<ToolbarItem>
<StyledLinkButton>
<Link />
</StyledLinkButton>
</ToolbarItem>
{/*<ToolbarItem><SectionSelect tags={["h1", "h2", "h3"]} /></ToolbarItem>*/}
{/*<ToolbarItem><AttributeSelect attribute="text-align" values={["left", "center", "right"]} /></ToolbarItem>*/}
<ToolbarItem>
<StyledSectionButton tag="blockquote">
<MessageCircle />
</StyledSectionButton>
</ToolbarItem>
<ToolbarItem>
<StyledSectionButton tag="ul">
<List />
</StyledSectionButton>
</ToolbarItem>
<ToolbarItem><StyledSectionButton tag="ol">1.</StyledSectionButton></ToolbarItem>
</ToolbarWrapper>
<MobileDocEditor />
</Container>
)
}

return null
}

Editor.propTypes = propTypes

export default Editor

+ 9
- 0
src/models.ts View File

@@ -0,0 +1,9 @@
import Folder from '../models/Folder'
import Tag from '../models/Tag'
import Note from '../models/Note'

export default [
Folder,
Note,
Tag,
]

+ 8
- 0
src/pages/_app.tsx View File

@@ -0,0 +1,8 @@
import * as React from 'react'
import 'mobiledoc-kit/dist/mobiledoc.css'

const App = ({ Component, pageProps }) => (
<Component {...pageProps} />
)

export default App

+ 32
- 0
src/pages/_document.tsx View File

@@ -0,0 +1,32 @@
import * as React from 'react'
import Document from 'next/document'
import { ServerStyleSheet } from 'styled-components'

export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage

try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
})

const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: (
<React.Fragment>
<style>{`:root { font-family: system-ui, sans-serif } body { margin: 0 }`}</style>
{initialProps.styles}
{sheet.getStyleElement()}
</React.Fragment>
),
}
} finally {
sheet.seal()
}
}
}

+ 5
- 0
src/pages/api/folders.ts View File

@@ -0,0 +1,5 @@
import Model from '../../../models/Folder'
import * as Service from '../../services/Folder'
import { collection } from '../../services/Controller'

export default collection(Model, Service)

+ 5
- 0
src/pages/api/folders/[id].ts View File

@@ -0,0 +1,5 @@
import Model from '../../../../models/Folder'
import * as Service from '../../../services/Folder'
import { item } from '../../../services/Controller'

export default item(Model, Service)

+ 5
- 0
src/pages/api/notes.ts View File

@@ -0,0 +1,5 @@
import Model from '../../../models/Note'
import * as Service from '../../services/Note'
import { collection } from '../../services/Controller'

export default collection(Model, Service)

+ 5
- 0
src/pages/api/notes/[id].ts View File

@@ -0,0 +1,5 @@
import Model from '../../../../models/Note'
import * as Service from '../../../services/Note'
import { item } from '../../../services/Controller'

export default item(Model, Service)

+ 299
- 0
src/pages/notes.tsx View File

@@ -0,0 +1,299 @@
import * as React from 'react'
import Head from 'next/head'
import styled from 'styled-components'
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 { XCircle } from 'react-feather'

const Navbar = styled('aside')({
width: 360,
height: '100%',
position: 'fixed',
top: 0,
left: -360,
backgroundColor: 'yellow',
'@media (min-width: 1080px)': {
width: `${100 / 3}%`,
left: 0,
},
})

const Main = styled('main')({
margin: '2rem 0',
'@media (min-width: 1080px)': {
paddingLeft: `${100 / 3}%`,
boxSizing: 'border-box',
},
})

const Container = styled('div')({
width: '100%',
margin: '0 auto',
padding: '0 1rem',
boxSizing: 'border-box',
'@media (min-width: 720px)': {
maxWidth: 720,
},
})

const NavbarContainer = styled('span')({
display: 'block',
width: '100%',
margin: '0 0 0 auto',
padding: '0 1rem',
boxSizing: 'border-box',
maxWidth: 360,
})

const TitleInput = styled('input')({
border: 0,
background: 'transparent',
padding: 0,
display: 'block',
width: '100%',
font: 'inherit',
fontSize: '3rem',
fontWeight: 'bold',
outline: 0,
marginBottom: '2rem',
})

const NoteLink = styled('a')({
display: 'flex',
textDecoration: 'none',
color: 'inherit',
height: '4rem',
alignItems: 'center',
position: 'relative',
})

const NoteLinkTitle = styled('strong')({
display: 'block',
})

const LinkContainer = styled('div')({
position: 'relative',
})

const NoteActions = styled('div')({
display: 'flex',
position: 'absolute',
alignItems: 'stretch',
top: 0,
right: 0,
height: '100%',
})

const NoteAction = styled('button')({
height: '100%',
width: '4rem',
background: 'transparent',
border: 0,
color: 'inherit',
cursor: 'pointer',
outline: 0,
})

const NoteLinkBackground = styled('span')({
opacity: 0.125,
backgroundColor: 'currentColor',
top: 0,
left: 0,
width: '100%',
height: '100%',
position: 'absolute',
})

type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, }

const Notes = ({ id: idProp }) => {
const [id, setId, ] = React.useState(idProp)
const [title, setTitle, ] = React.useState('')
const [notes, setNotes, ] = React.useState(null)
const [folders, setFolders, ] = React.useState(null)
const stateRef = React.useRef<NoteInstance>({ id, title: '', updatedAt: new Date().toISOString(), })
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,
title,
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 => {
setTitle(e.target.value)
autoSave()
}

React.useEffect(() => {
const loadNotes = async () => {
const theNotes = await Storage.loadNotes()
setNotes(theNotes)
}
loadNotes()
}, [])

React.useEffect(() => {
const loadFolders = async () => {
const theFolders = await Storage.loadFolders()
setFolders(theFolders)
}
loadFolders()
}, [])

React.useEffect(() => {
if (!Array.isArray(notes!)) {
return
}
const theNote = notes.find(n => n.id === id)
stateRef.current = theNote ? theNote : { id, title: '', updatedAt: new Date().toISOString(), }
setTitle(stateRef.current.title)
}, [id, notes])

React.useEffect(() => {
setId(idProp || generateId())
}, [idProp])

React.useEffect(() => {
autoSave()
}, [title])

return (
<React.Fragment>
<Head>
<title>{ idProp === undefined ? 'Notes | New Note' : `Notes | ${title.length > 0 ? title : '(untitled)'}`}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar>
<Link
href={{
pathname: '/notes',
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkTitle>
New Note
</NoteLinkTitle>
</NavbarContainer>
</NoteLink>
</Link>
{
Array.isArray(notes!)
&& notes.map(n => (
<LinkContainer
key={n.id}
>
{
n.id === id
&& (
<NoteLinkBackground />
)
}
<Link
href={{
pathname: '/notes/[id]',
query: { id: n.id },
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkTitle
style={{ opacity: n.title.length > 0 ? 1 : 0.5, }}
>
{n.title.length > 0 ? n.title : '(untitled)'}
</NoteLinkTitle>
{' '}
<small>
<time
dateTime={new Date(n.updatedAt).toISOString()}
>
Last updated {formatDate(new Date(n.updatedAt))}
</time>
</small>
</NavbarContainer>
</NoteLink>
</Link>
<NoteActions>
<NoteAction>
<XCircle />
</NoteAction>
</NoteActions>
</LinkContainer>
))
}
</Navbar>
<Main>
<Container>
{
Array.isArray(notes!)
&& (
<React.Fragment>
<TitleInput
placeholder="Title"
value={title}
onChange={handleTitleChange}
/>
<Editor
autoFocus={false}
key={id}
content={stateRef.current ? stateRef.current.content : undefined}
onChange={handleEditorChange}
placeholder="Start typing here."
/>
</React.Fragment>
)
}
</Container>
</Main>
</React.Fragment>
)
}

export const getServerSideProps = async ctx => {
if (ctx.params) {
return {
props: {
id: ctx.params?.id
}
}
}

return {
props: {}
}
}

export default Notes

+ 4
- 0
src/pages/notes/[id].tsx View File

@@ -0,0 +1,4 @@
import Notes, { getServerSideProps } from '../notes'

export default Notes
export { getServerSideProps }

+ 60
- 0
src/services/Controller.ts View File

@@ -0,0 +1,60 @@
import ORM, { DatabaseKind } from '../utilities/ORM'

export const collection = (Model, Service) => async (req, res) => {
const orm = new ORM({
kind: process.env.DATABASE_DIALECT as DatabaseKind,
url: process.env.DATABASE_URL,
})
const repository = orm.getRepository(Model)
const methodHandlers = {
'GET': Service.getMultiple(repository),
}

const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers
if (handler === null) {
res.statusCode = 415
res.json({ message: 'Method not allowed.' })
return
}

try {
const { status, data, } = await handler(req.query)
res.statusCode = status
res.json(data)
} catch (err) {
const { status, data, } = err
res.statusCode = status
res.json(data)
}
}

export const item = (Model, Service) => async (req, res) => {
const orm = new ORM({
kind: process.env.DATABASE_DIALECT as DatabaseKind,
url: process.env.DATABASE_URL,
})
const repository = orm.getRepository(Model)
const methodHandlers = {
'GET': Service.getSingle(repository),
'PUT': Service.save(repository)(req.body),
}

const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers
if (handler === null) {
res.statusCode = 415
res.json({ message: 'Method not allowed.' })
return
}

const { id } = req.query
try {
const { status, data, } = await handler(id)
res.statusCode = status
res.json(data)
} catch (err) {
console.log('ERROR', err)
const { status, data, } = err
res.statusCode = status
res.json(data)
}
}

+ 46
- 0
src/services/Folder.ts View File

@@ -0,0 +1,46 @@
import Model from '../../models/Folder'
import Instance from '../utilities/Instance'
import * as Response from '../utilities/Response'
type ModelInstance = Instance<typeof Model.rawAttributes>

export const getSingle = repository => async (id: string) => {
const instance = await repository.findByPk(id)
if (instance === null) {
throw new Response.NotFound({ message: 'Not found.' })
}
return new Response.Retrieved({
data: instance,
})
}

export const getMultiple = repository => async (query: Record<string, unknown>) => {
const instances = await repository.findAll()
return new Response.Retrieved({
data: instances,
})
}

export const save = repository => (body: Partial<ModelInstance>) => async (id: string, idColumnName = 'id') => {
const [newInstance, created] = await repository.findOrCreate({
where: { [idColumnName]: id },
defaults: {
...body,
[idColumnName]: id,
},
})

if (created) {
return new Response.Created({
data: newInstance.toJSON()
})
}

Object.entries(body).forEach(([key, value]) => {
newInstance[key] = value
})
newInstance[idColumnName] = id
const updatedInstance = await newInstance.save()
return new Response.Saved({
data: updatedInstance.toJSON()
})
}

+ 69
- 0
src/services/Note.ts View File

@@ -0,0 +1,69 @@
import Model from '../../models/Note'
import Instance from '../utilities/Instance'
import * as Response from '../utilities/Response'
type ModelInstance = Instance<typeof Model.rawAttributes>

export const getSingle = repository => async (id: string) => {
const instanceDAO = await repository.findByPk(id)
if (instanceDAO === null) {
throw new Response.NotFound({ message: 'Not found.' })
}
const instance = instanceDAO.toJSON()
return new Response.Retrieved({
data: {
...instance,
content: instance.content ? JSON.parse(instance.content) : null,
},
})
}

export const getMultiple = repository => async (query: Record<string, unknown>) => {
const instances = await repository.findAll()
return new Response.Retrieved({
data: instances.map(instanceDAO => {
const instance = instanceDAO.toJSON()
return {
...instance,
content: instance.content ? JSON.parse(instance.content) : null,
}
}),
})
}

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: {
...etcBody,
[idColumnName]: id,
content,
},
})

if (created) {
const newInstance = dao.toJSON()
return new Response.Created({
data: {
...newInstance,
content: newInstance.content ? JSON.parse(newInstance.content) : null,
},
})
}

Object.entries(body).forEach(([key, value]) => {
dao[key] = value
})
dao['content'] = content
dao[idColumnName] = id
const updatedDAO = await dao.save()
const updatedInstance = updatedDAO.toJSON()
return new Response.Saved({
data: {
...updatedInstance,
content: updatedInstance.content ? JSON.parse(updatedInstance.content) : null,
}
})
}

+ 60
- 0
src/services/Storage.ts View File

@@ -0,0 +1,60 @@
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())
}

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)
}
return theFolders
}

export const saveNote = async params => {
const {
id,
title,
content,
} = params
const response = await window.fetch(`/api/notes/${id}`, {
method: 'put',
body: JSON.stringify({
title,
content,
}),
headers: {
'Content-Type': 'application/json',
},
})

const body = await response.json()
if (response.status !== 201 && response.status !== 200) {
throw body
}
return body
}

+ 16
- 0
src/utilities/Date.ts View File

@@ -0,0 +1,16 @@
type FormatDate = (d: Date) => string

export const formatDate: FormatDate = d => {
const yearRaw = d.getFullYear()
const monthRaw = d.getMonth() + 1
const dateRaw = d.getDate()
const hourRaw = d.getHours()
const minuteRaw = d.getMinutes()
const year = yearRaw.toString().padStart(4, '0')
const month = monthRaw.toString().padStart(2, '0')
const date = dateRaw.toString().padStart(2, '0')
const hour = hourRaw.toString().padStart(2, '0')
const minute = minuteRaw.toString().padStart(2, '0')

return `${year}-${month}-${date} ${hour}:${minute}`
}

+ 5
- 0
src/utilities/Id.ts View File

@@ -0,0 +1,5 @@
import { v4 } from 'uuid'

const generateId = () => v4()

export default generateId

+ 5
- 0
src/utilities/Instance.ts View File

@@ -0,0 +1,5 @@
type Instance<T> = {
[key in keyof T]: unknown
}

export default Instance

+ 46
- 0
src/utilities/ORM.ts View File

@@ -0,0 +1,46 @@
import { Sequelize, Dialect, ModelAttributes, ModelOptions } from 'sequelize'

export enum DatabaseKind {
MSSQL = 'mssql',
SQLITE = 'sqlite',
MYSQL = 'mysql',
MARIADB = 'mariadb',
POSTGRESQL = 'postgres',
}

type RepositoryParams = {
url: string,
kind: DatabaseKind,
}

export default class ORM {
private readonly instance: Sequelize

constructor(params: RepositoryParams) {
const { kind, url, } = params
switch (kind as DatabaseKind) {
case DatabaseKind.SQLITE:
this.instance = new Sequelize({
dialect: kind as string as Dialect,
storage: url,
})
return
case DatabaseKind.POSTGRESQL:
case DatabaseKind.MARIADB:
case DatabaseKind.MSSQL:
case DatabaseKind.MYSQL:
this.instance = new Sequelize({
dialect: kind as string as Dialect,
host: url,
})
return
default:
break
}
throw new Error(`Database kind "${kind as string}" not yet supported.`)
}

getRepository(model: { modelName: string, rawAttributes: ModelAttributes, options: ModelOptions }) {
return this.instance.define(model.modelName, model.rawAttributes, model.options)
}
}

+ 45
- 0
src/utilities/Response.ts View File

@@ -0,0 +1,45 @@
interface Response<T = Record<string, unknown>> {
status: number,
data?: T,
}

interface ErrorResponse extends Response {
message: string,
}

type ResponseParams<T extends Record<string, unknown> = Record<string, unknown>> = {
message?: string,
data?: T,
}

export class NotFound implements ErrorResponse {
public readonly status = 404
public readonly message: string
constructor(params: ResponseParams) {
this.message = params.message
}
}

export class Created<T extends Record<string, unknown>> implements Response {
public readonly status = 201
public readonly data: T
constructor(params: ResponseParams<T>) {
this.data = params.data
}
}

export class Saved<T extends Record<string, unknown>> implements Response {
public readonly status = 200
public readonly data: T
constructor(params: ResponseParams<T>) {
this.data = params.data
}
}

export class Retrieved<T extends Record<string, unknown>> implements Response {
public readonly status = 200
public readonly data: T
constructor(params: ResponseParams<T>) {
this.data = params.data
}
}

+ 0
- 122
styles/Home.module.css View File

@@ -1,122 +0,0 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

.main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

.footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}

.footer img {
margin-left: 0.5rem;
}

.footer a {
display: flex;
justify-content: center;
align-items: center;
}

.title a {
color: #0070f3;
text-decoration: none;
}

.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}

.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}

.title,
.description {
text-align: center;
}

.description {
line-height: 1.5;
font-size: 1.5rem;
}

.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}

.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}

.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}

.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}

.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}

.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}

.logo {
height: 1em;
}

@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

+ 0
- 16
styles/globals.css View File

@@ -1,16 +0,0 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}

a {
color: inherit;
text-decoration: none;
}

* {
box-sizing: border-box;
}

+ 35
- 0
tsconfig.json View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "es6",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"declaration": true,
"declarationDir": "./dist",
"sourceMap": true,
"strict": false
},
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx"
],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
]
}

+ 1283
- 34
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save