@@ -0,0 +1,12 @@ | |||
[submodule "packages/app"] | |||
path = packages/app | |||
url = git@code.modal.sh:zeichen/app.git | |||
[submodule "packages/core"] | |||
path = packages/core | |||
url = git@code.modal.sh:zeichen/core.git | |||
[submodule "packages/plugin-local-storage"] | |||
path = packages/plugin-local-storage | |||
url = git@code.modal.sh:zeichen/plugin-local-storage.git | |||
[submodule "packages/plugin-remote-storage"] | |||
path = packages/plugin-remote-storage | |||
url = git@code.modal.sh:zeichen/plugin-remote-storage.git |
@@ -0,0 +1 @@ | |||
Subproject commit 8ccaaef742ccdf75adafb31fdcc99a408cf5cd89 |
@@ -1,9 +0,0 @@ | |||
{ | |||
"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" | |||
] | |||
} |
@@ -1,7 +0,0 @@ | |||
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') | |||
}; |
@@ -1,30 +0,0 @@ | |||
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. |
@@ -1,18 +0,0 @@ | |||
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 | |||
} | |||
} |
@@ -1,25 +0,0 @@ | |||
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) | |||
} |
@@ -1,4 +0,0 @@ | |||
/// <reference types="next" /> | |||
/// <reference types="next/types/global" /> | |||
declare module 'react-mobiledoc-editor' |
@@ -1,15 +0,0 @@ | |||
module.exports = { | |||
poweredByHeader: false, | |||
devIndicators: { | |||
autoPrerender: false, | |||
}, | |||
async redirects() { | |||
return [ | |||
{ | |||
source: '/', | |||
destination: '/notes?parentFolderId=00000000-0000-0000-000000000000', | |||
permanent: true, | |||
}, | |||
] | |||
}, | |||
} |
@@ -1,37 +0,0 @@ | |||
{ | |||
"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" | |||
} | |||
} |
@@ -1,4 +0,0 @@ | |||
<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> |
@@ -1,9 +0,0 @@ | |||
:root { | |||
background-color: var(--color-bg); | |||
color: var(--color-fg); | |||
font-family: var(--font-family-base), sans-serif; | |||
} | |||
body { | |||
margin: 0; | |||
} |
@@ -1,155 +0,0 @@ | |||
/** | |||
* 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; | |||
} |
@@ -1,10 +0,0 @@ | |||
: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; | |||
} |
@@ -1,149 +0,0 @@ | |||
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 |
@@ -1,36 +0,0 @@ | |||
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 |
@@ -1,282 +0,0 @@ | |||
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 |
@@ -1,112 +0,0 @@ | |||
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 |
@@ -1,201 +0,0 @@ | |||
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 |
@@ -1,6 +0,0 @@ | |||
import * as Storage from '../services/Storage' | |||
export const load = async ({ setFolders, }) => { | |||
const theFolders = await Storage.loadFolders() | |||
setFolders(theFolders) | |||
} |
@@ -1,104 +0,0 @@ | |||
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) | |||
} |
@@ -1,13 +0,0 @@ | |||
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, | |||
] |
@@ -1,42 +0,0 @@ | |||
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 |
@@ -1,46 +0,0 @@ | |||
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 |
@@ -1,22 +0,0 @@ | |||
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 |
@@ -1,22 +0,0 @@ | |||
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 |
@@ -1,34 +0,0 @@ | |||
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 |
@@ -1,10 +0,0 @@ | |||
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 |
@@ -1,31 +0,0 @@ | |||
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() | |||
} | |||
} | |||
} |
@@ -1,36 +0,0 @@ | |||
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() | |||
} | |||
} |
@@ -1,43 +0,0 @@ | |||
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() | |||
} | |||
} |
@@ -1,36 +0,0 @@ | |||
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() | |||
} | |||
} |
@@ -1,43 +0,0 @@ | |||
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() | |||
} | |||
} |
@@ -1,315 +0,0 @@ | |||
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 |
@@ -1,4 +0,0 @@ | |||
import Notes, { getServerSideProps } from '../notes' | |||
export default Notes | |||
export { getServerSideProps } |
@@ -1,13 +0,0 @@ | |||
import Operation from './services/Operation' | |||
export default { | |||
'operations': ( | |||
Object | |||
.entries(Operation) | |||
.filter(([, value]) => !isNaN(Number(value))) | |||
.map(([name, id]) => ({ | |||
id: Number(id), | |||
name, | |||
})) | |||
), | |||
} |
@@ -1,23 +0,0 @@ | |||
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) | |||
} | |||
} |
@@ -1,15 +0,0 @@ | |||
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 |
@@ -1,110 +0,0 @@ | |||
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' }) |
@@ -1,64 +0,0 @@ | |||
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() | |||
} |
@@ -1,78 +0,0 @@ | |||
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() | |||
} |
@@ -1,60 +0,0 @@ | |||
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, | |||
} |
@@ -1,98 +0,0 @@ | |||
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.') | |||
} | |||
} |
@@ -1,5 +0,0 @@ | |||
import { v4 } from 'uuid' | |||
const generateId = () => v4() | |||
export default generateId |
@@ -1,51 +0,0 @@ | |||
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.') | |||
} | |||
} |
@@ -1,49 +0,0 @@ | |||
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 | |||
} |
@@ -1,3 +0,0 @@ | |||
export const serialize = JSON.stringify | |||
export const deserialize = JSON.parse |
@@ -1,35 +0,0 @@ | |||
{ | |||
"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" | |||
] | |||
} |
@@ -1,9 +0,0 @@ | |||
import LocalStorage from '../plugin-local-storage/src' | |||
import RemoteStorage from '../plugin-remote-storage/src' | |||
export default { | |||
plugins: [ | |||
LocalStorage, | |||
RemoteStorage({ baseUrl: 'https://localhost:3000/api', }), | |||
] | |||
} |
@@ -0,0 +1 @@ | |||
Subproject commit f1019fe92d89b6285be4e56543e53b9dd908ddb6 |
@@ -1,2 +0,0 @@ | |||
*.ts | |||
tsconfig.json |
@@ -1,3 +0,0 @@ | |||
# Zeichen - Core | |||
Library for authoring Zeichen plugins and themes. |
@@ -1,11 +0,0 @@ | |||
{ | |||
"name": "zeichen-core", | |||
"description": "Library for authoring Zeichen plugins and themes.", | |||
"version": "0.1.0", | |||
"devDependencies": { | |||
"typescript": "^4.0.3" | |||
}, | |||
"scripts": { | |||
"build": "tsc **/*.ts" | |||
} | |||
} |
@@ -1,5 +0,0 @@ | |||
export type State = { | |||
currentUserId: string, | |||
} | |||
export type Plugin<T = {}> = (config: T) => (state: State) => void |
@@ -1,16 +0,0 @@ | |||
export default interface Storage<T> { | |||
queryItems(): Promise<T[]> | |||
saveItem(item: T): Promise<void> | |||
deleteItem(item: T): Promise<boolean> | |||
} | |||
export interface Collection<T> { | |||
items: T[], | |||
lastModifiedBy: string, | |||
lastModifiedAt: Date, | |||
} | |||
export type Serializer<T = unknown> = (t: T) => string | |||
export type Deserializer<T = unknown> = (s: string) => T | |||
export class OutOfSyncError {} |
@@ -1,28 +0,0 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es6", | |||
"lib": [ | |||
"dom", | |||
"dom.iterable", | |||
"esnext" | |||
], | |||
"skipLibCheck": true, | |||
"esModuleInterop": true, | |||
"allowSyntheticDefaultImports": true, | |||
"forceConsistentCasingInFileNames": true, | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"declaration": true, | |||
"declarationDir": "./dist", | |||
"emitDeclarationOnly": true, | |||
"sourceMap": true, | |||
"strict": false | |||
}, | |||
"exclude": [ | |||
"node_modules", | |||
"**/*.test.ts" | |||
], | |||
"include": [ | |||
"**/*.ts" | |||
] | |||
} |
@@ -0,0 +1 @@ | |||
Subproject commit 2fb6af99ba99525cf9ed62849992b4495381a7b5 |
@@ -1,2 +0,0 @@ | |||
*.ts | |||
tsconfig.json |
@@ -1,3 +0,0 @@ | |||
# Zeichen - Local Storage Plugin | |||
Save and load notes in Zeichen using the browser's local storage. |
@@ -1,18 +0,0 @@ | |||
{ | |||
"name": "zeichen-plugin-local-storage", | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"version": "0.1.0", | |||
"description": "Save and load notes in Zeichen using the browser's local storage.", | |||
"keywords": [ | |||
"zeichen", | |||
"plugin", | |||
"storage", | |||
"local" | |||
], | |||
"devDependencies": { | |||
"typescript": "^4.0.3" | |||
}, | |||
"scripts": { | |||
"build": "tsc **/*.ts" | |||
} | |||
} |
@@ -1,23 +0,0 @@ | |||
export default class LocalStorage<T> { | |||
constructor( | |||
private readonly source: Storage, | |||
private readonly serializer: (t: T) => string = JSON.stringify, | |||
private readonly deserializer: (s: string) => T = JSON.parse, | |||
) {} | |||
getCollection(collectionId: string) { | |||
const raw = this.source.getItem(collectionId) | |||
if (raw !== null) { | |||
return this.deserializer(raw) | |||
} | |||
return null | |||
} | |||
replaceCollection(collectionId: string, collectionData: T) { | |||
this.source.setItem(collectionId, this.serializer(collectionData)) | |||
} | |||
removeCollection(collectionId: string) { | |||
this.source.removeItem(collectionId) | |||
} | |||
} |
@@ -1,55 +0,0 @@ | |||
import Storage, { Collection, OutOfSyncError } from '../../core/src/storage' | |||
import Engine from './Engine' | |||
export default class LocalStorage<T> implements Storage<T> { | |||
private readonly engine: Engine<Collection<T>> | |||
constructor( | |||
private readonly ownerId: string, | |||
private readonly storageId: string, | |||
private readonly getItemId = item => item['id'], | |||
) { | |||
this.engine = new Engine<Collection<T>>(window.localStorage) | |||
} | |||
private getMeta() { | |||
const oldMeta = this.engine.getCollection(this.storageId) | |||
if (oldMeta === null) { | |||
throw new OutOfSyncError() | |||
} | |||
return oldMeta | |||
} | |||
private existenceCheck(newItem: T) { | |||
return oldItem => this.getItemId(oldItem) === this.getItemId(newItem) | |||
} | |||
async queryItems() { | |||
return this.getMeta().items | |||
} | |||
async saveItem(newItem: T) { | |||
const oldMeta = this.getMeta() | |||
const isExistingItem = oldMeta.items.some(this.existenceCheck(newItem)) | |||
const newMeta: Collection<T> = { | |||
items: isExistingItem | |||
? oldMeta.items.map(oldItem => this.existenceCheck(newItem)(oldItem) ? newItem : oldItem) | |||
: [...oldMeta.items, newItem], | |||
lastModifiedBy: this.ownerId, | |||
lastModifiedAt: new Date(), | |||
} | |||
this.engine.replaceCollection(this.storageId, newMeta) | |||
} | |||
async deleteItem(newItem: T) { | |||
const oldMeta = this.getMeta() | |||
const newItems = oldMeta.items.filter(oldItem => !this.existenceCheck(newItem)(oldItem)) | |||
const newMeta: Collection<T> = { | |||
items: newItems, | |||
lastModifiedBy: this.ownerId, | |||
lastModifiedAt: new Date(), | |||
} | |||
this.engine.replaceCollection(this.storageId, newMeta) | |||
return oldMeta.items.length !== newItems.length | |||
} | |||
} |
@@ -1,13 +0,0 @@ | |||
import { Plugin } from '../../core/src/plugin' | |||
import Storage from './Storage' | |||
type PluginConfig = {} | |||
const LocalStoragePlugin: Plugin<PluginConfig> = config => ({ | |||
currentUserId, | |||
}) => { | |||
new Storage(currentUserId, 'notes') | |||
new Storage(currentUserId, 'folders') | |||
} | |||
export default LocalStoragePlugin |
@@ -1,27 +0,0 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es6", | |||
"lib": [ | |||
"dom", | |||
"dom.iterable", | |||
"esnext" | |||
], | |||
"skipLibCheck": true, | |||
"esModuleInterop": true, | |||
"allowSyntheticDefaultImports": true, | |||
"forceConsistentCasingInFileNames": true, | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"declaration": true, | |||
"declarationDir": "./dist", | |||
"sourceMap": true, | |||
"strict": false | |||
}, | |||
"exclude": [ | |||
"node_modules", | |||
"**/*.test.ts" | |||
], | |||
"include": [ | |||
"**/*.ts" | |||
] | |||
} |
@@ -0,0 +1 @@ | |||
Subproject commit 6983443f63334697fa640693f8be724c4d5a61b1 |
@@ -1,2 +0,0 @@ | |||
*.ts | |||
tsconfig.json |
@@ -1,3 +0,0 @@ | |||
# Zeichen - Remote Storage Plugin | |||
Save and load notes in Zeichen using an external API. |
@@ -1,18 +0,0 @@ | |||
{ | |||
"name": "zeichen-plugin-remote-storage", | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"version": "0.1.0", | |||
"description": "Save and load notes in Zeichen using an external API.", | |||
"keywords": [ | |||
"zeichen", | |||
"plugin", | |||
"storage", | |||
"API" | |||
], | |||
"devDependencies": { | |||
"typescript": "^4.0.3" | |||
}, | |||
"scripts": { | |||
"build": "tsc **/*.ts" | |||
} | |||
} |
@@ -1,70 +0,0 @@ | |||
import { Deserializer, Serializer } from '../../core/src/storage' | |||
const CREATED = 201 | |||
const NO_CONTENT = 204 | |||
const NOT_FOUND = 404 | |||
const GONE = 410 | |||
export default class RemoteStorage<U, T> { | |||
constructor( | |||
private readonly baseUrl: string, | |||
private readonly getItemId = item => item['id'], | |||
private readonly serializers: Map<string, Serializer> = new Map([ | |||
['*/*', JSON.stringify], | |||
['application/json', JSON.stringify], | |||
['text/json', JSON.stringify], | |||
]), | |||
private readonly deserializers: Map<string, Deserializer> = new Map([ | |||
['*/*', JSON.parse], | |||
['application/json', JSON.parse], | |||
['text/json', JSON.parse], | |||
]), | |||
) {} | |||
async getCollection(collectionId: string) { | |||
const response = await window.fetch([this.baseUrl, collectionId].join('/')) | |||
const contentType = response.headers.get('content-type') | |||
const { [contentType]: deserializer = this.deserializers.get('*/*'), } = Object.fromEntries(this.deserializers.entries()) | |||
const payload = await response.text() | |||
return deserializer(payload) | |||
} | |||
async setItem(collectionId: string, item: U, contentType = 'application/json') { | |||
const { [contentType]: serializer = this.serializers.get('application/json'), } = Object.fromEntries(this.serializers.entries()) | |||
const response = await window.fetch([this.baseUrl, collectionId, this.getItemId(item)].join('/'), { | |||
method: 'put', | |||
body: serializer(item), | |||
}) | |||
// resource is created | |||
if (response.status === CREATED) { | |||
return | |||
} | |||
if (100 <= response.status && response.status <= 399) { | |||
console.warn(`Expected response is ${CREATED}, got ${response.status}.`) | |||
return | |||
} | |||
throw new Error(response.statusText) | |||
} | |||
async removeItem(collectionId: string, item: U) { | |||
const response = await window.fetch([this.baseUrl, collectionId, this.getItemId(item)].join('/'), { | |||
method: 'delete', | |||
}) | |||
// resource is deleted | |||
if (response.status === NO_CONTENT) { | |||
return true | |||
} | |||
// resource is already deleted | |||
if (response.status === NOT_FOUND || response.status === GONE) { | |||
return false | |||
} | |||
if (100 <= response.status && response.status <= 399) { | |||
console.warn(`Expected response is ${NO_CONTENT}, got ${response.status}.`) | |||
// assume there's a change in the collection | |||
return true | |||
} | |||
throw new Error(response.statusText) | |||
} | |||
// TODO do removeCollection for account closing? | |||
} |
@@ -1,27 +0,0 @@ | |||
import Storage, { Collection } from '../../core/src/storage' | |||
import Engine from './Engine' | |||
export default class RemoteStorage<T> implements Storage<T> { | |||
private readonly engine: Engine<T, Collection<T>> | |||
constructor( | |||
private ownerId: string, | |||
private baseUrl: string, | |||
private collectionId: string, | |||
) { | |||
this.engine = new Engine(baseUrl) | |||
} | |||
async deleteItem(item: T) { | |||
return this.engine.removeItem(this.collectionId, item) | |||
} | |||
async queryItems() { | |||
const response = await this.engine.getCollection(this.collectionId) as Collection<T>; | |||
return response.items; | |||
} | |||
async saveItem(newItem: T) { | |||
return this.engine.setItem(this.collectionId, newItem) | |||
} | |||
} |
@@ -1,15 +0,0 @@ | |||
import { Plugin } from '../../core/src/plugin' | |||
import Storage from './Storage' | |||
type PluginConfig = { | |||
baseUrl: string, | |||
} | |||
const RemoteStoragePlugin: Plugin<PluginConfig> = config => ({ | |||
currentUserId, | |||
}) => { | |||
new Storage(currentUserId, config.baseUrl, 'notes') | |||
new Storage(currentUserId, config.baseUrl, 'folders') | |||
} | |||
export default RemoteStoragePlugin |
@@ -1,27 +0,0 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es6", | |||
"lib": [ | |||
"dom", | |||
"dom.iterable", | |||
"esnext" | |||
], | |||
"skipLibCheck": true, | |||
"esModuleInterop": true, | |||
"allowSyntheticDefaultImports": true, | |||
"forceConsistentCasingInFileNames": true, | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"declaration": true, | |||
"declarationDir": "./dist", | |||
"sourceMap": true, | |||
"strict": false | |||
}, | |||
"exclude": [ | |||
"node_modules", | |||
"**/*.test.ts" | |||
], | |||
"include": [ | |||
"**/*.ts" | |||
] | |||
} |