@@ -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,39 @@ | |||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||||
# dependencies | |||||
node_modules/ | |||||
.pnp/ | |||||
.pnp.js | |||||
# testing | |||||
coverage/ | |||||
# next.js | |||||
.next/ | |||||
out/ | |||||
# production | |||||
build/ | |||||
# misc | |||||
.DS_Store | |||||
*.pem | |||||
# debug | |||||
npm-debug.log* | |||||
yarn-debug.log* | |||||
yarn-error.log* | |||||
# local env files | |||||
.env | |||||
.env.local | |||||
.env.development.local | |||||
.env.test.local | |||||
.env.production.local | |||||
# vercel | |||||
.vercel | |||||
database/ | |||||
.bin/ | |||||
.idea/ |
@@ -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,30 @@ | |||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). | |||||
## Getting Started | |||||
First, run the development server: | |||||
```bash | |||||
npm run dev | |||||
# or | |||||
yarn dev | |||||
``` | |||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | |||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. | |||||
## Learn More | |||||
To learn more about Next.js, take a look at the following resources: | |||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! | |||||
## Deploy on Vercel | |||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. |
@@ -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,25 @@ | |||||
import models from './src/models' | |||||
import seeds from './src/seeds' | |||||
// TODO support NoSQL | |||||
export const up = async queryInterface => { | |||||
const createTablePromises = models.map(m => queryInterface.createTable(m.tableName, m.attributes)) | |||||
await Promise.all(createTablePromises) | |||||
const seedTablePromise = models | |||||
.filter(m => Boolean(seeds[m.modelName])) | |||||
.map(m => { | |||||
console.log(JSON.stringify(seeds[m.modelName])) | |||||
return queryInterface.bulkInsert(m.tableName, seeds[m.tableName]) | |||||
}) | |||||
return Promise.all(seedTablePromise) | |||||
} | |||||
export const down = async queryInterface => { | |||||
const dropTablePromises = models | |||||
.reduce((reverse, m) => [m, ...reverse], []) | |||||
.map(m => queryInterface.dropTable(m.tableName)) | |||||
return Promise.all(dropTablePromises) | |||||
} |
@@ -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?parentFolderId=00000000-0000-0000-000000000000', | |||||
permanent: true, | |||||
}, | |||||
] | |||||
}, | |||||
} |
@@ -0,0 +1,37 @@ | |||||
{ | |||||
"name": "zeichen-app", | |||||
"version": "0.1.0", | |||||
"private": true, | |||||
"scripts": { | |||||
"dev": "next dev", | |||||
"build": "next build", | |||||
"start": "next start", | |||||
"migrate": "tsc migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate", | |||||
"migrate:run": "sequelize-cli db:migrate" | |||||
}, | |||||
"dependencies": { | |||||
"@fingerprintjs/fingerprintjs": "^3.0.3", | |||||
"dotenv": "^8.2.0", | |||||
"mobiledoc-kit": "^0.13.1", | |||||
"next": "9.5.5", | |||||
"react": "17.0.1", | |||||
"react-dom": "17.0.1", | |||||
"react-feather": "^2.0.8", | |||||
"react-mobiledoc-editor": "^0.10.0", | |||||
"sequelize": "5.22.0", | |||||
"styled-components": "^5.2.0", | |||||
"uuid": "^8.3.1" | |||||
}, | |||||
"devDependencies": { | |||||
"@types/node": "^14.14.2", | |||||
"@types/react": "^16.9.53", | |||||
"@types/styled-components": "^5.1.4", | |||||
"@types/uuid": "^8.3.0", | |||||
"typescript": "^4.0.3", | |||||
"sequelize-cli": "^6.2.0", | |||||
"sqlite3": "^5.0.0" | |||||
}, | |||||
"optionalDependencies": { | |||||
"sqlite3": "^5.0.0" | |||||
} | |||||
} |
@@ -0,0 +1,4 @@ | |||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none" | |||||
xmlns="http://www.w3.org/2000/svg"> | |||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/> | |||||
</svg> |
@@ -0,0 +1,9 @@ | |||||
:root { | |||||
background-color: var(--color-bg); | |||||
color: var(--color-fg); | |||||
font-family: var(--font-family-base), sans-serif; | |||||
} | |||||
body { | |||||
margin: 0; | |||||
} |
@@ -0,0 +1,155 @@ | |||||
/** | |||||
* Editor | |||||
*/ | |||||
.__mobiledoc-editor { | |||||
font-family: var(--font-family-body), serif; | |||||
margin: 1em 0; | |||||
color: #454545; | |||||
font-size: 1.2em; | |||||
line-height: 1.6em; | |||||
position: relative; | |||||
min-height: 1em; | |||||
} | |||||
.__mobiledoc-editor:focus { | |||||
outline: none; | |||||
} | |||||
.__mobiledoc-editor > * { | |||||
position: relative; | |||||
} | |||||
.__mobiledoc-editor.__has-no-content:after { | |||||
content: attr(data-placeholder); | |||||
color: #bbb; | |||||
cursor: text; | |||||
position: absolute; | |||||
top: 0; | |||||
} | |||||
.__mobiledoc-editor a { | |||||
color: var(--color-primary); | |||||
white-space: nowrap; | |||||
} | |||||
.__mobiledoc-editor h1, | |||||
.__mobiledoc-editor h2, | |||||
.__mobiledoc-editor h3, | |||||
.__mobiledoc-editor h4, | |||||
.__mobiledoc-editor h5, | |||||
.__mobiledoc-editor h6 { | |||||
font-family: var(--font-family-base), sans-serif; | |||||
letter-spacing: -0.02em; | |||||
} | |||||
.__mobiledoc-editor blockquote { | |||||
border-left: 4px solid var(--color-primary); | |||||
margin: 1em 0 1em -1.2em; | |||||
padding-left: 1.05em; | |||||
color: #a0a0a0; | |||||
} | |||||
.__mobiledoc-editor img { | |||||
display: block; | |||||
max-width: 100%; | |||||
margin: 0 auto; | |||||
} | |||||
.__mobiledoc-editor div, | |||||
.__mobiledoc-editor iframe { | |||||
max-width: 100%; | |||||
} | |||||
.__mobiledoc-editor [data-md-text-align='left'] { | |||||
text-align: left; | |||||
} | |||||
.__mobiledoc-editor [data-md-text-align='center'] { | |||||
text-align: center; | |||||
} | |||||
.__mobiledoc-editor [data-md-text-align='right'] { | |||||
text-align: right; | |||||
} | |||||
.__mobiledoc-editor [data-md-text-align='justify'] { | |||||
text-align: justify; | |||||
} | |||||
.__mobiledoc-editor ol, | |||||
.__mobiledoc-editor ul { | |||||
list-style-position: inside; | |||||
} | |||||
/** | |||||
* Cards | |||||
*/ | |||||
.__mobiledoc-card { | |||||
display: inline-block; | |||||
} | |||||
/** | |||||
* Tooltips | |||||
*/ | |||||
@-webkit-keyframes fade-in { | |||||
0% { opacity: 0; } | |||||
100% { opacity: 1; } | |||||
} | |||||
@keyframes fade-in { | |||||
0% { opacity: 0; } | |||||
100% { opacity: 1; } | |||||
} | |||||
.__mobiledoc-tooltip { | |||||
font-family: var(--font-family-base), sans-serif; | |||||
font-size: 0.7em; | |||||
white-space: nowrap; | |||||
position: absolute; | |||||
background-color: rgba(43,43,43,0.9); | |||||
border-radius: 3px; | |||||
line-height: 1em; | |||||
padding: 0.7em 0.9em; | |||||
color: #FFF; | |||||
-webkit-animation: fade-in 0.2s; | |||||
animation: fade-in 0.2s; | |||||
} | |||||
.__mobiledoc-tooltip:before { | |||||
content: ''; | |||||
position: absolute; | |||||
left: 50%; | |||||
width: 0; | |||||
height: 0; | |||||
border-left: 5px solid transparent; | |||||
border-right: 5px solid transparent; | |||||
border-bottom: 5px solid rgba(43,43,43,0.9); | |||||
top: -5px; | |||||
margin-left: -5px; | |||||
} | |||||
/* help keeps mouseover state when moving from link to tooltip */ | |||||
.__mobiledoc-tooltip:after { | |||||
content: ''; | |||||
position: absolute; | |||||
left: 0; | |||||
right: 0; | |||||
top: -5px; | |||||
height: 5px; | |||||
} | |||||
.__mobiledoc-tooltip a { | |||||
color: #FFF; | |||||
text-decoration: none; | |||||
} | |||||
.__mobiledoc-tooltip a:hover { | |||||
text-decoration: underline; | |||||
} | |||||
.__mobiledoc-tooltip__edit-link { | |||||
margin-left: 5px; | |||||
cursor: pointer; | |||||
} |
@@ -0,0 +1,10 @@ | |||||
:root { | |||||
--color-positive: #222; | |||||
--color-negative: #fff; | |||||
--color-accent: #7c47d2; | |||||
--color-bg: var(--color-negative); | |||||
--color-fg: var(--color-positive); | |||||
--color-primary: var(--color-accent); | |||||
--font-family-base: system-ui; | |||||
--font-family-body: Georgia; | |||||
} |
@@ -0,0 +1,149 @@ | |||||
import * as React from 'react' | |||||
import * as PropTypes from 'prop-types' | |||||
import { | |||||
Container, | |||||
MarkupButton, | |||||
LinkButton, | |||||
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 | |||||
style={{ | |||||
color: 'inherit', | |||||
}} | |||||
/> | |||||
</Container> | |||||
) | |||||
} | |||||
return null | |||||
} | |||||
Editor.propTypes = propTypes | |||||
export default Editor |
@@ -0,0 +1,36 @@ | |||||
import * as React from 'react' | |||||
import * as PropTypes from 'prop-types' | |||||
import { ArrowLeft, Search, FilePlus, FileText, FolderPlus, GitBranch, Trash2, User, Menu } from 'react-feather' | |||||
const DEFINED_ICONS = { | |||||
'note': FileText, | |||||
'mind-map': GitBranch, | |||||
'user': User, | |||||
'new-folder': FolderPlus, | |||||
'new-note': FilePlus, | |||||
'bin': Trash2, | |||||
'back': ArrowLeft, | |||||
'search': Search, | |||||
'menu': Menu, | |||||
} | |||||
const propTypes = { | |||||
name: PropTypes.oneOf(Object.keys(DEFINED_ICONS)).isRequired, | |||||
} | |||||
type Props = PropTypes.InferProps<typeof propTypes> | |||||
const Icon: React.FC<Props> = ({ | |||||
name | |||||
}) => { | |||||
const { [name as keyof typeof DEFINED_ICONS]: Component = null } = DEFINED_ICONS | |||||
if (Component !== null) { | |||||
return <Component /> | |||||
} | |||||
return null | |||||
} | |||||
Icon.propTypes = propTypes | |||||
export default Icon |
@@ -0,0 +1,282 @@ | |||||
import * as React from 'react' | |||||
import styled, { createGlobalStyle } from 'styled-components' | |||||
import SecondaryNavItem from '../SecondaryNavItem/SecondaryNavItem' | |||||
import PrimaryNavItem from '../PrimaryNavItem/PrimaryNavItem' | |||||
import Link from 'next/link' | |||||
const Base = styled('aside')({ | |||||
width: 360, | |||||
height: '100%', | |||||
position: 'fixed', | |||||
top: 0, | |||||
left: -360, | |||||
backgroundColor: 'var(--color-fg)', | |||||
color: 'var(--color-bg)', | |||||
zIndex: 1, | |||||
'@media (min-width: 1080px)': { | |||||
width: `${100 / 3}%`, | |||||
left: 0, | |||||
}, | |||||
}) | |||||
const PrimaryNavItems = styled('nav')({ | |||||
width: '100%', | |||||
height: '4rem', | |||||
position: 'fixed', | |||||
bottom: 0, | |||||
left: 0, | |||||
zIndex: 1, | |||||
backgroundColor: 'var(--color-fg)', | |||||
color: 'var(--color-bg)', | |||||
justifyContent: 'center', | |||||
'@media (min-width: 1080px)': { | |||||
position: 'static', | |||||
bottom: 'auto', | |||||
left: 'auto', | |||||
width: '4rem', | |||||
height: '100%', | |||||
}, | |||||
}) | |||||
const PrimaryNavItemsContainer = styled('div')({ | |||||
width: '100%', | |||||
height: '100%', | |||||
maxWidth: 720, | |||||
display: 'flex', | |||||
margin: '0 auto', | |||||
'@media (min-width: 1080px)': { | |||||
width: '100%', | |||||
flexDirection: 'column', | |||||
justifyContent: 'space-between', | |||||
alignItems: 'stretch', | |||||
}, | |||||
}) | |||||
const PrimaryNavItemGroup = styled('div')({ | |||||
display: 'contents', | |||||
'@media (min-width: 1080px)': { | |||||
display: 'block', | |||||
}, | |||||
}) | |||||
const SecondaryNavItems = styled('nav')({ | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'fixed', | |||||
top: 0, | |||||
left: 0, | |||||
paddingBottom: '4rem', | |||||
boxSizing: 'border-box', | |||||
pointerEvents: 'none', | |||||
'@media (min-width: 1080px)': { | |||||
position: 'relative', | |||||
top: 'auto', | |||||
left: 'auto', | |||||
flex: 'auto', | |||||
width: 'auto', | |||||
paddingBottom: 0, | |||||
pointerEvents: 'initial', | |||||
}, | |||||
}) | |||||
const SecondaryNavItemsFg = styled('div')({ | |||||
height: '100%', | |||||
backgroundColor: 'var(--color-bg)', | |||||
width: 360, | |||||
position: 'absolute', | |||||
top: 0, | |||||
left: -360, | |||||
paddingBottom: '4rem', | |||||
boxSizing: 'border-box', | |||||
transitionProperty: 'left', | |||||
transitionDuration: '350ms', | |||||
transitionTimingFunction: 'ease-out', | |||||
'::before': { | |||||
content: "''", | |||||
display: 'block', | |||||
top: 0, | |||||
left: 0, | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'absolute', | |||||
backgroundColor: 'black', | |||||
opacity: 0.03125, | |||||
}, | |||||
'@media (min-width: 1080px)': { | |||||
position: 'relative', | |||||
top: 'auto', | |||||
left: 'auto', | |||||
flex: 'auto', | |||||
width: 'auto', | |||||
paddingBottom: 0, | |||||
}, | |||||
}) | |||||
const SecondaryNavItemsBg = styled('a')({ | |||||
opacity: 0, | |||||
transitionProperty: 'opacity', | |||||
transitionDuration: '350ms', | |||||
transitionTimingFunction: 'ease-out', | |||||
'::before': { | |||||
content: "''", | |||||
display: 'block', | |||||
top: 0, | |||||
left: 0, | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'absolute', | |||||
backgroundColor: 'black', | |||||
opacity: 0.75, | |||||
}, | |||||
}) | |||||
const SecondaryNavItemsOverflow = styled('div')({ | |||||
overflow: 'auto', | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'relative', | |||||
}) | |||||
const NavbarItems = styled('div')({ | |||||
display: 'flex', | |||||
width: '100%', | |||||
height: '100%', | |||||
}) | |||||
const NavbarContainer = styled('div')({ | |||||
display: 'block', | |||||
width: '100%', | |||||
height: '100%', | |||||
margin: '0 0 0 auto', | |||||
boxSizing: 'border-box', | |||||
maxWidth: 360, | |||||
}) | |||||
const SecondaryVisibleDummy = styled('div')({ | |||||
display: 'none', | |||||
}) | |||||
const Visible = createGlobalStyle({ | |||||
[`div + ${SecondaryNavItems}`]: { | |||||
pointerEvents: 'initial', | |||||
}, | |||||
[`div + ${SecondaryNavItems} ${SecondaryNavItemsFg}`]: { | |||||
left: 0, | |||||
}, | |||||
[`div + ${SecondaryNavItems} ${SecondaryNavItemsBg}`]: { | |||||
opacity: 1, | |||||
}, | |||||
'@media (min-width: 1080px)': { | |||||
[`div + ${SecondaryNavItems} ${SecondaryNavItemsFg}`]: { | |||||
left: 'auto', | |||||
}, | |||||
[`div + ${SecondaryNavItems} ${SecondaryNavItemsBg}`]: { | |||||
opacity: 0, | |||||
}, | |||||
} | |||||
}) | |||||
const Navbar = ({ | |||||
closeHref, | |||||
secondaryVisible, | |||||
primaryItemsStart = [], | |||||
primaryItemsEnd = [], | |||||
secondaryItemsHeader = [], | |||||
secondaryItems = [], | |||||
}) => ( | |||||
<React.Fragment> | |||||
<Visible /> | |||||
<Base> | |||||
<NavbarContainer> | |||||
<NavbarItems> | |||||
<PrimaryNavItems> | |||||
<PrimaryNavItemsContainer> | |||||
<PrimaryNavItemGroup> | |||||
{ | |||||
Array.isArray(primaryItemsStart!) | |||||
&& primaryItemsStart.map(i => ( | |||||
<PrimaryNavItem | |||||
key={i.id} | |||||
mobileOnly={i.mobileOnly} | |||||
href={i.href} | |||||
iconName={i.iconName} | |||||
title={i.title} | |||||
active={i.active} | |||||
/> | |||||
)) | |||||
} | |||||
</PrimaryNavItemGroup> | |||||
{ | |||||
Array.isArray(primaryItemsEnd!) | |||||
&& ( | |||||
<PrimaryNavItemGroup> | |||||
{ | |||||
primaryItemsEnd.map(i => ( | |||||
<PrimaryNavItem | |||||
key={i.id} | |||||
href={i.href} | |||||
iconName={i.iconName} | |||||
title={i.title} | |||||
active={i.active} | |||||
/> | |||||
)) | |||||
} | |||||
</PrimaryNavItemGroup> | |||||
) | |||||
} | |||||
</PrimaryNavItemsContainer> | |||||
</PrimaryNavItems> | |||||
{ | |||||
secondaryVisible | |||||
&& ( | |||||
<SecondaryVisibleDummy /> | |||||
) | |||||
} | |||||
<SecondaryNavItems> | |||||
<Link | |||||
href={closeHref} | |||||
passHref | |||||
shallow | |||||
> | |||||
<SecondaryNavItemsBg /> | |||||
</Link> | |||||
<SecondaryNavItemsFg> | |||||
<SecondaryNavItemsOverflow> | |||||
{ | |||||
secondaryItemsHeader.map(i => ( | |||||
<SecondaryNavItem | |||||
key={i.id} | |||||
active={i.active} | |||||
href={i.href} | |||||
replace={i.replace} | |||||
iconName={i.iconName} | |||||
title={i.title} | |||||
subtitle={i.subtitle} | |||||
actions={i.actions} | |||||
/> | |||||
)) | |||||
} | |||||
{ | |||||
secondaryItems.map(i => ( | |||||
<SecondaryNavItem | |||||
key={i.id} | |||||
active={i.active} | |||||
href={i.href} | |||||
replace={i.replace} | |||||
iconName={i.iconName} | |||||
title={i.title} | |||||
subtitle={i.subtitle} | |||||
actions={i.actions} | |||||
/> | |||||
)) | |||||
} | |||||
</SecondaryNavItemsOverflow> | |||||
</SecondaryNavItemsFg> | |||||
</SecondaryNavItems> | |||||
</NavbarItems> | |||||
</NavbarContainer> | |||||
</Base> | |||||
</React.Fragment> | |||||
) | |||||
export default Navbar |
@@ -0,0 +1,112 @@ | |||||
import * as React from 'react' | |||||
import * as PropTypes from 'prop-types' | |||||
import Link from 'next/link' | |||||
import styled from 'styled-components' | |||||
import Icon from '../Icon/Icon' | |||||
const Base = styled('div')({ | |||||
width: 0, | |||||
height: '4rem', | |||||
flex: 'auto', | |||||
padding: '0.25rem', | |||||
boxSizing: 'border-box', | |||||
position: 'relative', | |||||
'@media (min-width: 1080px)': { | |||||
width: '4rem', | |||||
height: '4rem', | |||||
}, | |||||
}) | |||||
const ClickArea = styled('a')({ | |||||
display: 'grid', | |||||
color: 'inherit', | |||||
placeContent: 'center', | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'absolute', | |||||
top: 0, | |||||
left: 0, | |||||
textDecoration: 'none', | |||||
}) | |||||
const ClickAreaContent = styled('span')({ | |||||
display: 'block', | |||||
textAlign: 'center', | |||||
lineHeight: 1, | |||||
}) | |||||
const Highlight = styled('div')({ | |||||
width: '100%', | |||||
height: '100%', | |||||
backgroundColor: 'var(--color-primary)', | |||||
opacity: 0.5, | |||||
borderRadius: '0.25rem', | |||||
[`+ ${ClickArea}`]: { | |||||
opacity: 0.75, | |||||
}, | |||||
}) | |||||
const Title = styled('small')({ | |||||
display: 'block', | |||||
fontWeight: 'bolder', | |||||
position: 'absolute', | |||||
left: -999999, | |||||
}) | |||||
const MobileBase = styled(Base)({ | |||||
'@media (min-width: 1080px)': { | |||||
display: 'none', | |||||
}, | |||||
}) | |||||
const propTypes = { | |||||
href: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, | |||||
iconName: PropTypes.string.isRequired, | |||||
title: PropTypes.string, | |||||
mobileOnly: PropTypes.bool, | |||||
active: PropTypes.bool, | |||||
} | |||||
type Props = PropTypes.InferProps<typeof propTypes> | |||||
const PrimaryNavItem: React.FC<Props> = ({ | |||||
href, | |||||
iconName, | |||||
title, | |||||
mobileOnly = false, | |||||
active = false, | |||||
}) => { | |||||
const BaseComponent = mobileOnly ? MobileBase : Base | |||||
return ( | |||||
<BaseComponent> | |||||
{ | |||||
active | |||||
&& ( | |||||
<Highlight /> | |||||
) | |||||
} | |||||
<Link | |||||
href={href} | |||||
passHref | |||||
shallow | |||||
> | |||||
<ClickArea | |||||
title={title} | |||||
> | |||||
<ClickAreaContent> | |||||
<Icon | |||||
name={iconName} | |||||
/> | |||||
<Title> | |||||
{title} | |||||
</Title> | |||||
</ClickAreaContent> | |||||
</ClickArea> | |||||
</Link> | |||||
</BaseComponent> | |||||
) | |||||
} | |||||
PrimaryNavItem.propTypes = propTypes | |||||
export default PrimaryNavItem |
@@ -0,0 +1,201 @@ | |||||
import * as React from 'react' | |||||
import * as PropTypes from 'prop-types' | |||||
import Link from 'next/link' | |||||
import styled from 'styled-components' | |||||
import Icon from '../Icon/Icon' | |||||
const NoteLink = styled('a')({ | |||||
display: 'flex', | |||||
textDecoration: 'none', | |||||
color: 'inherit', | |||||
alignItems: 'center', | |||||
position: 'relative', | |||||
flex: 'auto', | |||||
padding: '0 1rem', | |||||
boxSizing: 'border-box', | |||||
}) | |||||
const NoteLinkPrimary = styled('span')({ | |||||
display: 'block', | |||||
flex: 'auto', | |||||
}) | |||||
const NoteLinkTitle = styled('strong')({ | |||||
display: 'block', | |||||
height: '1.25em', | |||||
position: 'relative', | |||||
}) | |||||
const NoteLinkTitleOverflow = styled('span')({ | |||||
whiteSpace: 'nowrap', | |||||
overflow: 'hidden', | |||||
position: 'absolute', | |||||
top: 0, | |||||
left: 0, | |||||
width: '100%', | |||||
height: '100%', | |||||
textOverflow: 'ellipsis', | |||||
}) | |||||
const LinkContainer = styled('div')({ | |||||
position: 'relative', | |||||
color: 'var(--color-primary, blue)', | |||||
display: 'flex', | |||||
alignItems: 'stretch', | |||||
height: '4rem', | |||||
}) | |||||
const NoteActions = styled('div')({ | |||||
display: 'flex', | |||||
alignItems: 'stretch', | |||||
height: '100%', | |||||
position: 'relative', | |||||
'@media (min-width: 1080px)': { | |||||
opacity: 0, | |||||
[`${LinkContainer}:hover &`]: { | |||||
opacity: 1, | |||||
}, | |||||
}, | |||||
}) | |||||
const NoteAction = styled('button')({ | |||||
height: '100%', | |||||
width: '4rem', | |||||
background: 'transparent', | |||||
border: 0, | |||||
color: 'inherit', | |||||
cursor: 'pointer', | |||||
outline: 0, | |||||
padding: 0, | |||||
}) | |||||
const NoteLinkBackground = styled('span')({ | |||||
'::before': { | |||||
content: "''", | |||||
position: 'absolute', | |||||
top: 0, | |||||
left: 0, | |||||
width: '0.25rem', | |||||
height: '100%', | |||||
display: 'block', | |||||
backgroundColor: 'currentColor', | |||||
}, | |||||
'::after': { | |||||
content: "''", | |||||
opacity: 0.125, | |||||
backgroundColor: 'currentColor', | |||||
top: 0, | |||||
left: 0, | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'absolute', | |||||
}, | |||||
}) | |||||
const IconContainer = styled('span')({ | |||||
display: 'inline-block', | |||||
verticalAlign: 'middle', | |||||
marginRight: '0.5rem', | |||||
}) | |||||
const PostMeta = styled('small')({ | |||||
opacity: 0.5, | |||||
height: '1.25rem', | |||||
display: 'block', | |||||
lineHeight: 1.25, | |||||
}) | |||||
const propTypes = { | |||||
active: PropTypes.bool, | |||||
href: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, | |||||
replace: PropTypes.bool, | |||||
iconName: PropTypes.string, | |||||
title: PropTypes.string, | |||||
subtitle: PropTypes.node, | |||||
actions: PropTypes.arrayOf(PropTypes.shape({ | |||||
id: PropTypes.string.isRequired, | |||||
onClick: PropTypes.func, | |||||
iconName: PropTypes.string, | |||||
})), | |||||
} | |||||
type Props = PropTypes.InferProps<typeof propTypes> | |||||
const SecondaryNavItem: React.FC<Props> = ({ | |||||
active = false, | |||||
href, | |||||
replace = false, | |||||
iconName, | |||||
title, | |||||
subtitle, | |||||
actions, | |||||
}) => ( | |||||
<LinkContainer> | |||||
{ | |||||
active | |||||
&& ( | |||||
<NoteLinkBackground /> | |||||
) | |||||
} | |||||
<Link | |||||
href={href} | |||||
replace={replace} | |||||
passHref | |||||
shallow | |||||
> | |||||
<NoteLink> | |||||
<IconContainer> | |||||
{ | |||||
iconName | |||||
&& ( | |||||
<Icon | |||||
name={iconName} | |||||
/> | |||||
) | |||||
} | |||||
</IconContainer> | |||||
<NoteLinkPrimary> | |||||
<NoteLinkTitle | |||||
style={{ opacity: title.length > 0 ? 1 : 0.5, }} | |||||
> | |||||
<NoteLinkTitleOverflow> | |||||
{title.length > 0 ? title : '(untitled)'} | |||||
</NoteLinkTitleOverflow> | |||||
</NoteLinkTitle> | |||||
{ | |||||
subtitle | |||||
&& ( | |||||
<React.Fragment> | |||||
{' '} | |||||
<PostMeta> | |||||
{subtitle} | |||||
</PostMeta> | |||||
</React.Fragment> | |||||
) | |||||
} | |||||
</NoteLinkPrimary> | |||||
</NoteLink> | |||||
</Link> | |||||
{ | |||||
Array.isArray(actions) | |||||
&& ( | |||||
<NoteActions> | |||||
{actions.map(a => ( | |||||
<NoteAction | |||||
key={a.id} | |||||
onClick={a.onClick} | |||||
> | |||||
<Icon | |||||
name={a.iconName} | |||||
/> | |||||
</NoteAction> | |||||
))} | |||||
</NoteActions> | |||||
) | |||||
} | |||||
</LinkContainer> | |||||
) | |||||
SecondaryNavItem.propTypes = propTypes | |||||
export default SecondaryNavItem |
@@ -0,0 +1,6 @@ | |||||
import * as Storage from '../services/Storage' | |||||
export const load = async ({ setFolders, }) => { | |||||
const theFolders = await Storage.loadFolders() | |||||
setFolders(theFolders) | |||||
} |
@@ -0,0 +1,104 @@ | |||||
import * as Storage from '../services/Storage' | |||||
const save = async ({ stateRef, router, id, setNotes, }) => { | |||||
stateRef.current.updatedAt = new Date().toISOString() | |||||
const newNote = await Storage.saveNote(stateRef.current) | |||||
if (router.query.id !== id) { | |||||
await router.replace( | |||||
{ | |||||
pathname: '/notes/[id]', | |||||
query: { 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)) | |||||
}) | |||||
} | |||||
const triggerAutoSave = ({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
}) => { | |||||
if (timeoutRef.current !== null) { | |||||
clearTimeout(timeoutRef.current) | |||||
} | |||||
timeoutRef.current = setTimeout(async () => { | |||||
await save({ stateRef, router, id, setNotes, }) | |||||
timeoutRef.current = null | |||||
}, 3000) | |||||
} | |||||
export const updateContent = ({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
}) => e => { | |||||
stateRef.current.content = e | |||||
triggerAutoSave({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
}) | |||||
} | |||||
export const updateTitle = ({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
setTitle, | |||||
}) => e => { | |||||
setTitle(stateRef.current.title = e.target.value) | |||||
triggerAutoSave({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
}) | |||||
} | |||||
export const remove = ({ | |||||
setNotes, | |||||
notes, | |||||
router, | |||||
}) => note => async () => { | |||||
setNotes(notes.filter(n => n.id !== note.id)) | |||||
await router.replace( | |||||
{ | |||||
pathname: '/notes', | |||||
}, | |||||
undefined, | |||||
{ | |||||
shallow: true, | |||||
} | |||||
) | |||||
const result = await Storage.deleteNote(note) | |||||
if (!result) { | |||||
setNotes(notes) | |||||
} | |||||
} | |||||
export const load = async ({ setNotes, }) => { | |||||
const theNotes = await Storage.loadNotes() | |||||
setNotes(theNotes) | |||||
} |
@@ -0,0 +1,13 @@ | |||||
import Folder from './models/Folder' | |||||
import Note from './models/Note' | |||||
import Tag from './models/Tag' | |||||
import Operation from './models/Operation' | |||||
import Transaction from './models/Transaction' | |||||
export default [ | |||||
Folder, | |||||
Note, | |||||
Tag, | |||||
Operation, | |||||
Transaction, | |||||
] |
@@ -0,0 +1,42 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Folder: ColumnTypes.Model = { | |||||
modelName: 'Folder', | |||||
tableName: 'folders', | |||||
options: { | |||||
timestamps: true, | |||||
paranoid: true, | |||||
createdAt: 'createdAt', | |||||
updatedAt: 'updatedAt', | |||||
deletedAt: 'deletedAt', | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
name: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
parentId: { | |||||
allowNull: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
createdAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
updatedAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
deletedAt: { | |||||
allowNull: true, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
} | |||||
} | |||||
export default Folder |
@@ -0,0 +1,46 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Note: ColumnTypes.Model = { | |||||
modelName: 'Note', | |||||
tableName: 'notes', | |||||
options: { | |||||
timestamps: true, | |||||
paranoid: true, | |||||
createdAt: 'createdAt', | |||||
updatedAt: 'updatedAt', | |||||
deletedAt: 'deletedAt', | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
title: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
content: { | |||||
allowNull: true, | |||||
type: ColumnTypes.TEXT({ length: 'long', }), | |||||
}, | |||||
folderId: { | |||||
allowNull: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
createdAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
updatedAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
deletedAt: { | |||||
allowNull: true, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
}, | |||||
} | |||||
export default Note |
@@ -0,0 +1,22 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Operation: ColumnTypes.Model = { | |||||
modelName: 'Operation', | |||||
tableName: 'operations', | |||||
options: { | |||||
timestamps: false, | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.INTEGER, | |||||
}, | |||||
name: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
} | |||||
} | |||||
export default Operation |
@@ -0,0 +1,22 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Tag: ColumnTypes.Model = { | |||||
modelName: 'Tag', | |||||
tableName: 'tags', | |||||
options: { | |||||
timestamps: false, | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
name: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
} | |||||
} | |||||
export default Tag |
@@ -0,0 +1,34 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Transaction: ColumnTypes.Model = { | |||||
modelName: 'Transaction', | |||||
tableName: 'transactions', | |||||
options: { | |||||
timestamps: false, | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
deviceId: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
operation: { | |||||
allowNull: false, | |||||
type: ColumnTypes.INTEGER, | |||||
}, | |||||
objectId: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
performedAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
}, | |||||
} | |||||
export default Transaction |
@@ -0,0 +1,10 @@ | |||||
import * as React from 'react' | |||||
import '../assets/theme.css' | |||||
import '../assets/global.css' | |||||
import '../assets/mobiledoc.css' | |||||
const App = ({ Component, pageProps }) => ( | |||||
<Component {...pageProps} /> | |||||
) | |||||
export default App |
@@ -0,0 +1,31 @@ | |||||
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> | |||||
{initialProps.styles} | |||||
{sheet.getStyleElement()} | |||||
</React.Fragment> | |||||
), | |||||
} | |||||
} finally { | |||||
sheet.seal() | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,36 @@ | |||||
import ORM, { DatabaseKind } from '../../utilities/ORM' | |||||
import * as Service from '../../services/entities/Folder' | |||||
import Model from '../../models/Folder' | |||||
export default 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) { | |||||
console.error(err) | |||||
const { status, data, } = err | |||||
res.statusCode = status | |||||
if (data && status !== 204) { | |||||
res.json(data) | |||||
return | |||||
} | |||||
res.end() | |||||
} | |||||
} |
@@ -0,0 +1,43 @@ | |||||
import ORM, { DatabaseKind } from '../../../utilities/ORM' | |||||
import * as Service from '../../../services/entities/Folder' | |||||
import Model from '../../../models/Folder' | |||||
export default 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), | |||||
'DELETE': Service.remove(repository) | |||||
} | |||||
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, ...etcReturn } = await handler(id) | |||||
res.statusCode = status | |||||
if (etcReturn['data']) { | |||||
res.json(etcReturn['data']) | |||||
return | |||||
} | |||||
res.end() | |||||
} catch (err) { | |||||
console.error(err) | |||||
const { status, data, } = err | |||||
res.statusCode = status || 500 | |||||
if (data && status !== 204) { | |||||
res.json(data) | |||||
return | |||||
} | |||||
res.end() | |||||
} | |||||
} |
@@ -0,0 +1,36 @@ | |||||
import ORM, { DatabaseKind } from '../../utilities/ORM' | |||||
import * as Service from '../../services/entities/Note' | |||||
import Model from '../../models/Note' | |||||
export default 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) { | |||||
console.error(err) | |||||
const { status, data, } = err | |||||
res.statusCode = status | |||||
if (data && status !== 204) { | |||||
res.json(data) | |||||
return | |||||
} | |||||
res.end() | |||||
} | |||||
} |
@@ -0,0 +1,43 @@ | |||||
import ORM, { DatabaseKind } from '../../../utilities/ORM' | |||||
import * as Service from '../../../services/entities/Note' | |||||
import Model from '../../../models/Note' | |||||
export default 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), | |||||
'DELETE': Service.remove(repository) | |||||
} | |||||
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, ...etcReturn } = await handler(id) | |||||
res.statusCode = status | |||||
if (etcReturn['data']) { | |||||
res.json(etcReturn['data']) | |||||
return | |||||
} | |||||
res.end() | |||||
} catch (err) { | |||||
console.error(err) | |||||
const { status, data, } = err | |||||
res.statusCode = status || 500 | |||||
if (data && status !== 204) { | |||||
res.json(data) | |||||
return | |||||
} | |||||
res.end() | |||||
} | |||||
} |
@@ -0,0 +1,315 @@ | |||||
import * as React from 'react' | |||||
import Head from 'next/head' | |||||
import styled from 'styled-components' | |||||
import { useRouter } from 'next/router' | |||||
import Editor from '../components/Editor/Editor' | |||||
import Navbar from '../components/Navbar/Navbar' | |||||
import generateId from '../utilities/Id' | |||||
import { formatDate } from '../utilities/Date' | |||||
import * as Note from '../controllers/Note' | |||||
import * as Folder from '../controllers/Folder' | |||||
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 TitleInput = styled('input')({ | |||||
border: 0, | |||||
background: 'transparent', | |||||
padding: 0, | |||||
display: 'block', | |||||
width: '100%', | |||||
font: 'inherit', | |||||
fontSize: '3rem', | |||||
fontWeight: 'bold', | |||||
color: 'inherit', | |||||
outline: 0, | |||||
}) | |||||
const PostMeta = styled('small')({ | |||||
opacity: 0.5, | |||||
height: '1.25rem', | |||||
display: 'block', | |||||
lineHeight: 1.25, | |||||
}) | |||||
const PostPrimary = styled('div')({ | |||||
marginBottom: '2rem', | |||||
}) | |||||
type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, } | |||||
const Notes = ({ id: idProp }) => { | |||||
// TODO remove extra state for ID | |||||
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() | |||||
React.useEffect(() => { | |||||
Note.load({ setNotes }) | |||||
}, []) | |||||
React.useEffect(() => { | |||||
Folder.load({ setFolders }) | |||||
}, []) | |||||
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]) | |||||
return ( | |||||
<React.Fragment> | |||||
<Head> | |||||
<title>{ idProp === undefined ? 'Notes | New Note' : `Notes | Edit Note - ${title.length > 0 ? title : '(untitled)'}`}</title> | |||||
<link rel="icon" href="/favicon.ico" /> | |||||
</Head> | |||||
<Navbar | |||||
closeHref={{ | |||||
pathname: router.pathname, | |||||
query: Object | |||||
.entries(router.query) | |||||
.filter(([key]) => key !== 'navbar') | |||||
.reduce( | |||||
(theQuery, [key, value]) => ({ | |||||
...theQuery, | |||||
[key]: value, | |||||
}), | |||||
{} | |||||
), | |||||
}} | |||||
secondaryVisible={Boolean(router.query.navbar)} | |||||
primaryItemsStart={[ | |||||
{ | |||||
id: 'sidebar', | |||||
mobileOnly: true, | |||||
active: Boolean(router.query.navbar), | |||||
href: { | |||||
pathname: router.pathname, | |||||
query: { | |||||
...router.query, | |||||
navbar: 'true', | |||||
}, | |||||
}, | |||||
iconName: 'menu', | |||||
title: 'Menu', | |||||
}, | |||||
{ | |||||
id: 'folders', | |||||
active: router.pathname.startsWith('/notes') && !Boolean(router.query.navbar), | |||||
href: { | |||||
pathname: '/notes', | |||||
}, | |||||
iconName: 'note', | |||||
title: 'Notes', | |||||
}, | |||||
{ | |||||
id: 'search', | |||||
href: { | |||||
pathname: '/notes', | |||||
query: { | |||||
action: 'search', | |||||
}, | |||||
}, | |||||
iconName: 'search', | |||||
title: 'Search', | |||||
}, | |||||
{ | |||||
id: 'binned', | |||||
href: { | |||||
pathname: '/notes', | |||||
query: { | |||||
status: 'binned', | |||||
}, | |||||
}, | |||||
iconName: 'bin', | |||||
title: 'Bin', | |||||
}, | |||||
]} | |||||
primaryItemsEnd={[ | |||||
{ | |||||
id: 'user', | |||||
href: { | |||||
pathname: '/me', | |||||
}, | |||||
iconName: 'user', | |||||
title: 'User', | |||||
}, | |||||
]} | |||||
secondaryItemsHeader={[ | |||||
{ | |||||
id: 'parent', | |||||
href: { | |||||
pathname: '/notes', | |||||
query: { | |||||
folder: '00000000-0000-0000-000000000000', | |||||
navbar: router.query.navbar, | |||||
}, | |||||
}, | |||||
iconName: 'back', | |||||
title: 'Folder Name', | |||||
// todo use history back | |||||
}, | |||||
{ | |||||
id: 'note', | |||||
href: { | |||||
pathname: '/notes', | |||||
query: { | |||||
action: 'new', | |||||
parentFolderId: '00000000-0000-0000-000000000000', | |||||
}, | |||||
}, | |||||
iconName: 'new-note', | |||||
title: 'Create Note', | |||||
}, | |||||
{ | |||||
id: 'folder', | |||||
href: { | |||||
pathname: '/folders', | |||||
query: { | |||||
action: 'new', | |||||
parentFolderId: '00000000-0000-0000-000000000000', | |||||
}, | |||||
}, | |||||
iconName: 'new-folder', | |||||
title: 'Create Child Folder', | |||||
}, | |||||
{ | |||||
id: 'map', | |||||
href: { | |||||
pathname: '/notes', | |||||
query: { | |||||
action: 'view-map', | |||||
parentFolderId: '00000000-0000-0000-000000000000', | |||||
}, | |||||
}, | |||||
iconName: 'mind-map', | |||||
title: 'View Folder Mind Map', | |||||
}, | |||||
]} | |||||
secondaryItems={ | |||||
Array.isArray(notes!) | |||||
? notes.map(n => ({ | |||||
id: n.id, | |||||
active: n.id === id, | |||||
href: { | |||||
pathname: '/notes/[id]', | |||||
query: { id: n.id }, | |||||
}, | |||||
iconName: 'note', | |||||
replace: true, | |||||
title: n.title.trim(), | |||||
subtitle: ( | |||||
<time | |||||
dateTime={new Date(n.updatedAt).toISOString()} | |||||
> | |||||
{formatDate(new Date(n.updatedAt))} | |||||
</time> | |||||
), | |||||
actions: [ | |||||
{ | |||||
id: 'bin', | |||||
iconName: 'bin', | |||||
onClick: Note.remove({ setNotes, notes, router, })(n), | |||||
} | |||||
], | |||||
})) | |||||
: [] | |||||
} | |||||
/> | |||||
<Main> | |||||
<Container> | |||||
{ | |||||
Array.isArray(notes!) | |||||
&& ( | |||||
<React.Fragment> | |||||
<PostPrimary> | |||||
<TitleInput | |||||
placeholder="Title" | |||||
value={title} | |||||
onChange={Note.updateTitle({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
setTitle, | |||||
})} | |||||
/> | |||||
<PostMeta> | |||||
{ | |||||
stateRef.current.updatedAt | |||||
&& router.query.id | |||||
&& ( | |||||
<time | |||||
dateTime={new Date(stateRef.current.updatedAt).toISOString()} | |||||
> | |||||
Last updated {formatDate(new Date(stateRef.current.updatedAt))} | |||||
</time> | |||||
) | |||||
} | |||||
</PostMeta> | |||||
</PostPrimary> | |||||
<Editor | |||||
autoFocus={false} | |||||
key={id} | |||||
content={stateRef.current ? stateRef.current.content : undefined} | |||||
onChange={Note.updateContent({ | |||||
stateRef, | |||||
timeoutRef, | |||||
router, | |||||
id, | |||||
setNotes, | |||||
})} | |||||
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,13 @@ | |||||
import Operation from './services/Operation' | |||||
export default { | |||||
'operations': ( | |||||
Object | |||||
.entries(Operation) | |||||
.filter(([, value]) => !isNaN(Number(value))) | |||||
.map(([name, id]) => ({ | |||||
id: Number(id), | |||||
name, | |||||
})) | |||||
), | |||||
} |
@@ -0,0 +1,23 @@ | |||||
export default class LocalStorage<T> { | |||||
constructor( | |||||
private readonly source: Storage, | |||||
private readonly serializer: (t: T) => string, | |||||
private readonly deserializer: (s: string) => T | |||||
) {} | |||||
getItem(id: string, fallback: T = null) { | |||||
const raw = this.source.getItem(id) | |||||
if (raw === null) { | |||||
return fallback | |||||
} | |||||
return this.deserializer(raw) | |||||
} | |||||
setItem(id: string, item: T) { | |||||
this.source.setItem(id, this.serializer(item)) | |||||
} | |||||
removeItem(id: string) { | |||||
this.source.removeItem(id) | |||||
} | |||||
} |
@@ -0,0 +1,15 @@ | |||||
enum Operation { | |||||
'note:read' = 1, | |||||
'note:write' = 2, | |||||
'note:delete' = 3, | |||||
'note:import' = 4, | |||||
'note:export' = 5, | |||||
'folder:read' = 7, | |||||
'folder:write' = 8, | |||||
'social:publish' = 9, | |||||
'social:unpublish' = 10, | |||||
'social:share' = 11, | |||||
'userinfo:manage' = 12, | |||||
} | |||||
export default Operation |
@@ -0,0 +1,110 @@ | |||||
import { addTime, TimeDivision } from '../utilities/Date' | |||||
import * as Serialization from '../utilities/Serialization' | |||||
import NoteModel from '../models/Note' | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
import LocalStorage from './LocalStorage' | |||||
type StorageParams = { | |||||
id: string, | |||||
url: string, | |||||
} | |||||
type Note = ColumnTypes.InferModel<typeof NoteModel> | |||||
type LoadItems = <T extends Record<string, unknown>>(params: StorageParams) => () => Promise<T[]> | |||||
const loadItems: LoadItems = <T extends Record<string, unknown>>(params) => async (): Promise<T[]> => { | |||||
const { id, url, } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
) | |||||
const localData = storage.getItem(id) | |||||
if (localData === null) { | |||||
const remoteItems = await window.fetch(url) | |||||
// TODO add custom serialization method | |||||
const theItems: T[] = await remoteItems.json() | |||||
storage.setItem(id, { | |||||
// TODO backend should set expiry | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: theItems, | |||||
}) | |||||
return theItems | |||||
} | |||||
const dataExpiry = new Date(Number(localData.expiry)) | |||||
const now = new Date() | |||||
if (now.getTime() > dataExpiry.getTime()) { | |||||
storage.removeItem(id) | |||||
const loader: () => Promise<T[]> = loadItems(params) | |||||
return loader() | |||||
} | |||||
return localData.items | |||||
} | |||||
type SaveItem = <T extends Record<string, unknown>>(p: StorageParams) => (item: T) => Promise<T> | |||||
const saveItem: SaveItem = <T extends Record<string, unknown>>(params) => async (item) => { | |||||
const { id: storageId, url } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
) | |||||
const localData = storage.getItem(storageId, { | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: [], | |||||
}) | |||||
const { items: localItems } = localData | |||||
const { id: itemId, ...theBody } = item | |||||
const theItems: T[] = ( | |||||
localItems.some(i => i.id === itemId) | |||||
? localItems.map(i => i.id === itemId ? item : i) | |||||
: [...localItems, item] | |||||
) | |||||
storage.setItem(storageId, { ...localData, items: theItems }) | |||||
const response = await window.fetch(`${url}/${itemId}`, { | |||||
method: 'put', | |||||
body: JSON.stringify(theBody), | |||||
headers: { | |||||
'Content-Type': 'application/json', | |||||
}, | |||||
}) | |||||
const responseBody = await response.json() | |||||
if (response.status !== 201 && response.status !== 200) { | |||||
throw responseBody | |||||
} | |||||
return responseBody as T | |||||
} | |||||
type DeleteItem = <T extends Record<string, unknown>>(p: StorageParams) => (item: T) => Promise<boolean> | |||||
const deleteItem: DeleteItem = <T extends Record<string, unknown>>(params) => async (item) => { | |||||
const { id: storageId, url } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
) | |||||
const localData = storage.getItem(storageId, { | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: [], | |||||
}) | |||||
const { items: localItems } = localData | |||||
const { id: itemId } = item | |||||
const theItems: T[] = localItems.filter(i => i.id !== itemId) | |||||
storage.setItem(storageId, { ...localData, items: theItems }) | |||||
const response = await window.fetch(`${url}/${itemId}`, { | |||||
method: 'delete', | |||||
}) | |||||
return response.status === 204 | |||||
} | |||||
export const loadNotes = loadItems<Note>({ id: 'notes', url: '/api/notes' }) | |||||
export const loadFolders = loadItems({ id: 'folders', url: '/api/folders' }) | |||||
export const saveNote = saveItem<Note>({ id: 'notes', url: '/api/notes' }) | |||||
export const saveFolder = saveItem({ id: 'folders', url: '/api/folders' }) | |||||
export const deleteNote = deleteItem<Note>({ id: 'notes', url: '/api/notes' }) |
@@ -0,0 +1,64 @@ | |||||
import FolderModel from '../../models/Folder' | |||||
import * as ColumnTypes from '../../utilities/ColumnTypes' | |||||
import * as Response from '../../utilities/Response' | |||||
type Folder = ColumnTypes.InferModel<typeof FolderModel> | |||||
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.toJSON() as Folder, | |||||
}) | |||||
} | |||||
export const getMultiple = repository => async (query?: Record<string, unknown>) => { | |||||
const fetchMethod = async query => { | |||||
if (query) { | |||||
return repository.findWhere({ | |||||
attributes: query, | |||||
}) | |||||
} | |||||
return repository.findAll() | |||||
} | |||||
const instances = await fetchMethod(query) | |||||
return new Response.Retrieved<Folder[]>({ | |||||
data: instances.map(i => i.toJSON()), | |||||
}) | |||||
} | |||||
export const save = repository => (body: Partial<Folder>) => 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() as Folder | |||||
}) | |||||
} | |||||
Object.entries(body).forEach(([key, value]) => { | |||||
newInstance[key] = value | |||||
}) | |||||
newInstance[idColumnName] = id | |||||
const updatedInstance = await newInstance.save() | |||||
return new Response.Saved({ | |||||
data: updatedInstance.toJSON() as Folder | |||||
}) | |||||
} | |||||
export const remove = repository => async (id: string) => { | |||||
const instanceDAO = repository.findByPk(id) | |||||
if (instanceDAO === null) { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | |||||
} | |||||
await instanceDAO.destroy() | |||||
return new Response.Destroyed() | |||||
} |
@@ -0,0 +1,78 @@ | |||||
import Model from '../../models/Note' | |||||
import * as ColumnTypes from '../../utilities/ColumnTypes' | |||||
import * as Response from '../../utilities/Response' | |||||
type Note = ColumnTypes.InferModel<typeof Model> | |||||
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<Note>) => async (id: string, idColumnName = 'id') => { | |||||
const { content: contentRaw, ...etcBody } = body | |||||
const content = contentRaw! ? JSON.stringify(contentRaw) : null | |||||
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, | |||||
} | |||||
}) | |||||
} | |||||
export const remove = repository => async (id: string) => { | |||||
const instanceDAO = await repository.findByPk(id) | |||||
if (instanceDAO === null) { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | |||||
} | |||||
await instanceDAO.destroy() | |||||
return new Response.Destroyed() | |||||
} |
@@ -0,0 +1,60 @@ | |||||
import { | |||||
DataType, | |||||
INTEGER, | |||||
STRING, | |||||
TEXT, | |||||
DATE, | |||||
DATEONLY, | |||||
UUIDV4, | |||||
} from 'sequelize' | |||||
type ModelAttribute = { | |||||
allowNull?: boolean, | |||||
primaryKey?: boolean, | |||||
type: DataType, | |||||
} | |||||
export type Model = { | |||||
tableName?: string, | |||||
modelName?: string, | |||||
options?: { | |||||
timestamps?: boolean, | |||||
paranoid?: boolean, | |||||
createdAt?: string | boolean, | |||||
updatedAt?: string | boolean, | |||||
deletedAt?: string | boolean, | |||||
}, | |||||
attributes: Record<string, ModelAttribute>, | |||||
} | |||||
type IntegerType = typeof INTEGER | ReturnType<typeof INTEGER> | |||||
type NumberType = IntegerType | |||||
type VarcharType = typeof STRING | |||||
type TextType = typeof TEXT | ReturnType<typeof TEXT> | |||||
type StringType = VarcharType | TextType | |||||
type DateTimeType = typeof DATE | |||||
type DateOnlyType = typeof DATEONLY | |||||
type DateType = DateTimeType | DateOnlyType | |||||
type InferType<V extends DataType> = ( | |||||
V extends NumberType ? number : | |||||
V extends StringType ? string : | |||||
V extends DateType ? Date : | |||||
V extends typeof UUIDV4 ? string : | |||||
unknown | |||||
) | |||||
export type InferModel<M extends Model> = { | |||||
[K in keyof M['attributes']]-?: InferType<M['attributes'][K]['type']> | |||||
} | |||||
export { | |||||
INTEGER, | |||||
STRING, | |||||
TEXT, | |||||
DATEONLY, | |||||
DATE, | |||||
UUIDV4, | |||||
} |
@@ -0,0 +1,98 @@ | |||||
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}` | |||||
} | |||||
export enum TimeDivision { | |||||
MILLISECONDS, | |||||
SECONDS, | |||||
MINUTES, | |||||
HOURS, | |||||
DAYS | |||||
} | |||||
type GetTimeDifference = (a: Date, b: Date) => (c: TimeDivision) => number | |||||
export const getTimeDifference: GetTimeDifference = (a, b) => { | |||||
const ms = b.getTime() - a.getTime() | |||||
const absoluteMs = ms < 0 ? -ms : ms | |||||
return c => { | |||||
let divisionDifference = absoluteMs | |||||
if (c === TimeDivision.MILLISECONDS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 1000 | |||||
if (c === TimeDivision.SECONDS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 60 | |||||
if (c === TimeDivision.MINUTES) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 60 | |||||
if (c === TimeDivision.HOURS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 24 | |||||
if (c === TimeDivision.DAYS) { | |||||
return divisionDifference | |||||
} | |||||
throw new Error('Unknown time division.') | |||||
} | |||||
} | |||||
type AddTime = (refDate: Date, increment: number) => (c: TimeDivision) => Date | |||||
export const addTime: AddTime = (refDate, increment) => { | |||||
const futureDate = new Date(refDate.getTime()) | |||||
return c => { | |||||
let msIncrement = increment | |||||
if (c === TimeDivision.MILLISECONDS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 1000 | |||||
if (c === TimeDivision.SECONDS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 60 | |||||
if (c === TimeDivision.MINUTES) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 60 | |||||
if (c === TimeDivision.HOURS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 24 | |||||
if (c === TimeDivision.DAYS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
throw new Error('Unknown time division.') | |||||
} | |||||
} |
@@ -0,0 +1,5 @@ | |||||
import { v4 } from 'uuid' | |||||
const generateId = () => v4() | |||||
export default generateId |
@@ -0,0 +1,51 @@ | |||||
import { Sequelize, Dialect, } from 'sequelize' | |||||
import * as ColumnTypes from './ColumnTypes' | |||||
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 | |||||
// TODO add nosql dbs | |||||
default: | |||||
break | |||||
} | |||||
throw new Error(`Database kind "${kind as string}" not yet supported.`) | |||||
} | |||||
getRepository(model: ColumnTypes.Model) { | |||||
if (this.instance instanceof Sequelize) { | |||||
return this.instance.define(model.modelName, model.attributes, model.options) | |||||
} | |||||
throw new Error('Interface not yet initialized.') | |||||
} | |||||
} |
@@ -0,0 +1,49 @@ | |||||
interface Response<T extends {} = {}> { | |||||
status: number, | |||||
data?: T, | |||||
} | |||||
interface ErrorResponse extends Response { | |||||
message: string, | |||||
} | |||||
type ResponseParams<T extends {} = {}> = { | |||||
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 {}> implements Response { | |||||
public readonly status = 201 | |||||
public readonly data: T | |||||
constructor(params: ResponseParams<T>) { | |||||
this.data = params.data | |||||
} | |||||
} | |||||
export class Saved<T extends {}> implements Response { | |||||
public readonly status = 200 | |||||
public readonly data: T | |||||
constructor(params: ResponseParams<T>) { | |||||
this.data = params.data | |||||
} | |||||
} | |||||
export class Retrieved<T extends {}> implements Response { | |||||
public readonly status = 200 | |||||
public readonly data: T | |||||
constructor(params: ResponseParams<T>) { | |||||
this.data = params.data | |||||
} | |||||
} | |||||
export class Destroyed implements Response { | |||||
public readonly status = 204 | |||||
} |
@@ -0,0 +1,3 @@ | |||||
export const serialize = JSON.stringify | |||||
export const deserialize = JSON.parse |
@@ -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" | |||||
] | |||||
} |
@@ -0,0 +1,9 @@ | |||||
import LocalStorage from '../plugin-local-storage/src' | |||||
import RemoteStorage from '../plugin-remote-storage/src' | |||||
export default { | |||||
plugins: [ | |||||
LocalStorage, | |||||
RemoteStorage({ baseUrl: 'https://localhost:3000/api', }), | |||||
] | |||||
} |