@@ -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" | |||
] | |||
} |
@@ -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 |
@@ -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/ |
@@ -0,0 +1,9 @@ | |||
{ | |||
"jsxSingleQuote": false, | |||
"singleQuote": true, | |||
"printWidth": 120, | |||
"semi": false, | |||
"trailingComma": "all", | |||
"quoteProps": "as-needed", | |||
"arrowParens": "always" | |||
} |
@@ -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') | |||
}; |
@@ -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 | |||
} | |||
} |
@@ -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) | |||
}) | |||
} |
@@ -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[] | |||
// } | |||
// |
@@ -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 | |||
// } |
@@ -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 | |||
// } |
@@ -0,0 +1,4 @@ | |||
/// <reference types="next" /> | |||
/// <reference types="next/types/global" /> | |||
declare module 'react-mobiledoc-editor' |
@@ -0,0 +1,15 @@ | |||
module.exports = { | |||
poweredByHeader: false, | |||
devIndicators: { | |||
autoPrerender: false, | |||
}, | |||
async redirects() { | |||
return [ | |||
{ | |||
source: '/', | |||
destination: '/notes', | |||
permanent: true, | |||
}, | |||
] | |||
}, | |||
} |
@@ -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" | |||
} | |||
} |
@@ -1,7 +0,0 @@ | |||
import '../styles/globals.css' | |||
function MyApp({ Component, pageProps }) { | |||
return <Component {...pageProps} /> | |||
} | |||
export default MyApp |
@@ -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' }) | |||
} |
@@ -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 →</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 →</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 →</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 →</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> | |||
) | |||
} |
@@ -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 |
@@ -0,0 +1,9 @@ | |||
import Folder from '../models/Folder' | |||
import Tag from '../models/Tag' | |||
import Note from '../models/Note' | |||
export default [ | |||
Folder, | |||
Note, | |||
Tag, | |||
] |
@@ -0,0 +1,8 @@ | |||
import * as React from 'react' | |||
import 'mobiledoc-kit/dist/mobiledoc.css' | |||
const App = ({ Component, pageProps }) => ( | |||
<Component {...pageProps} /> | |||
) | |||
export default App |
@@ -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() | |||
} | |||
} | |||
} |
@@ -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) |
@@ -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) |
@@ -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) |
@@ -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) |
@@ -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 |
@@ -0,0 +1,4 @@ | |||
import Notes, { getServerSideProps } from '../notes' | |||
export default Notes | |||
export { getServerSideProps } |
@@ -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) | |||
} | |||
} |
@@ -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() | |||
}) | |||
} |
@@ -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, | |||
} | |||
}) | |||
} |
@@ -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 | |||
} |
@@ -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}` | |||
} |
@@ -0,0 +1,5 @@ | |||
import { v4 } from 'uuid' | |||
const generateId = () => v4() | |||
export default generateId |
@@ -0,0 +1,5 @@ | |||
type Instance<T> = { | |||
[key in keyof T]: unknown | |||
} | |||
export default Instance |
@@ -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) | |||
} | |||
} |
@@ -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 | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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; | |||
} |
@@ -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" | |||
] | |||
} |