@@ -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* | yarn-error.log* | ||||
# local env files | # local env files | ||||
.env | |||||
.env.local | .env.local | ||||
.env.development.local | .env.development.local | ||||
.env.test.local | .env.test.local | ||||
@@ -32,3 +33,7 @@ yarn-error.log* | |||||
# vercel | # vercel | ||||
.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": { | "scripts": { | ||||
"dev": "next dev", | "dev": "next dev", | ||||
"build": "next build", | "build": "next build", | ||||
"start": "next start" | |||||
"start": "next start", | |||||
"migrate": "tsc migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate" | |||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"dotenv": "^8.2.0", | |||||
"mobiledoc-kit": "^0.13.1", | |||||
"next": "9.5.5", | "next": "9.5.5", | ||||
"react": "17.0.1", | "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" | |||||
] | |||||
} |