Browse Source

Separate project

Extract project from monorepo.
master
TheoryOfNekomata 3 years ago
commit
8ccaaef742
50 changed files with 8674 additions and 0 deletions
  1. +9
    -0
      .babelrc
  2. +39
    -0
      .gitignore
  3. +7
    -0
      .sequelizerc
  4. +30
    -0
      README.md
  5. +18
    -0
      config/config.js
  6. +25
    -0
      migrate.ts
  7. +4
    -0
      next-env.d.ts
  8. +15
    -0
      next.config.js
  9. +37
    -0
      package.json
  10. BIN
      public/favicon.ico
  11. +4
    -0
      public/vercel.svg
  12. +9
    -0
      src/assets/global.css
  13. +155
    -0
      src/assets/mobiledoc.css
  14. +10
    -0
      src/assets/theme.css
  15. +149
    -0
      src/components/Editor/Editor.tsx
  16. +36
    -0
      src/components/Icon/Icon.tsx
  17. +282
    -0
      src/components/Navbar/Navbar.tsx
  18. +112
    -0
      src/components/PrimaryNavItem/PrimaryNavItem.tsx
  19. +201
    -0
      src/components/SecondaryNavItem/SecondaryNavItem.tsx
  20. +6
    -0
      src/controllers/Folder.ts
  21. +104
    -0
      src/controllers/Note.ts
  22. +13
    -0
      src/models.ts
  23. +42
    -0
      src/models/Folder.ts
  24. +46
    -0
      src/models/Note.ts
  25. +22
    -0
      src/models/Operation.ts
  26. +22
    -0
      src/models/Tag.ts
  27. +34
    -0
      src/models/Transaction.ts
  28. +10
    -0
      src/pages/_app.tsx
  29. +31
    -0
      src/pages/_document.tsx
  30. +36
    -0
      src/pages/api/folders.ts
  31. +43
    -0
      src/pages/api/folders/[id].ts
  32. +36
    -0
      src/pages/api/notes.ts
  33. +43
    -0
      src/pages/api/notes/[id].ts
  34. +315
    -0
      src/pages/notes.tsx
  35. +4
    -0
      src/pages/notes/[id].tsx
  36. +13
    -0
      src/seeds.ts
  37. +23
    -0
      src/services/LocalStorage.ts
  38. +15
    -0
      src/services/Operation.ts
  39. +110
    -0
      src/services/Storage.ts
  40. +64
    -0
      src/services/entities/Folder.ts
  41. +78
    -0
      src/services/entities/Note.ts
  42. +60
    -0
      src/utilities/ColumnTypes.ts
  43. +98
    -0
      src/utilities/Date.ts
  44. +5
    -0
      src/utilities/Id.ts
  45. +51
    -0
      src/utilities/ORM.ts
  46. +49
    -0
      src/utilities/Response.ts
  47. +3
    -0
      src/utilities/Serialization.ts
  48. +35
    -0
      tsconfig.json
  49. +6112
    -0
      yarn.lock
  50. +9
    -0
      zeichen.config.ts

+ 9
- 0
.babelrc View File

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

+ 39
- 0
.gitignore View File

@@ -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/

+ 7
- 0
.sequelizerc View File

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

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

+ 30
- 0
README.md View File

@@ -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.

+ 18
- 0
config/config.js View File

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

dotenv.config()

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

+ 25
- 0
migrate.ts View File

@@ -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)
}

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

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

declare module 'react-mobiledoc-editor'

+ 15
- 0
next.config.js View File

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

+ 37
- 0
package.json View File

@@ -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"
}
}

BIN
public/favicon.ico View File

Before After

+ 4
- 0
public/vercel.svg View File

@@ -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>

+ 9
- 0
src/assets/global.css View File

@@ -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;
}

+ 155
- 0
src/assets/mobiledoc.css View File

@@ -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;
}

+ 10
- 0
src/assets/theme.css View File

@@ -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;
}

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

@@ -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

+ 36
- 0
src/components/Icon/Icon.tsx View File

@@ -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

+ 282
- 0
src/components/Navbar/Navbar.tsx View File

@@ -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

+ 112
- 0
src/components/PrimaryNavItem/PrimaryNavItem.tsx View File

@@ -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

+ 201
- 0
src/components/SecondaryNavItem/SecondaryNavItem.tsx View File

@@ -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

+ 6
- 0
src/controllers/Folder.ts View File

@@ -0,0 +1,6 @@
import * as Storage from '../services/Storage'

export const load = async ({ setFolders, }) => {
const theFolders = await Storage.loadFolders()
setFolders(theFolders)
}

+ 104
- 0
src/controllers/Note.ts View File

@@ -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)
}

+ 13
- 0
src/models.ts View File

@@ -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,
]

+ 42
- 0
src/models/Folder.ts View File

@@ -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

+ 46
- 0
src/models/Note.ts View File

@@ -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

+ 22
- 0
src/models/Operation.ts View File

@@ -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

+ 22
- 0
src/models/Tag.ts View File

@@ -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

+ 34
- 0
src/models/Transaction.ts View File

@@ -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

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

@@ -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

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

@@ -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()
}
}
}

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

@@ -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()
}
}

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

@@ -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()
}
}

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

@@ -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()
}
}

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

@@ -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()
}
}

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

@@ -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

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

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

export default Notes
export { getServerSideProps }

+ 13
- 0
src/seeds.ts View File

@@ -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,
}))
),
}

+ 23
- 0
src/services/LocalStorage.ts View File

@@ -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)
}
}

+ 15
- 0
src/services/Operation.ts View File

@@ -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

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

@@ -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' })

+ 64
- 0
src/services/entities/Folder.ts View File

@@ -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()
}

+ 78
- 0
src/services/entities/Note.ts View File

@@ -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()
}

+ 60
- 0
src/utilities/ColumnTypes.ts View File

@@ -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,
}

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

@@ -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.')
}
}

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

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

const generateId = () => v4()

export default generateId

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

@@ -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.')
}
}

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

@@ -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
}

+ 3
- 0
src/utilities/Serialization.ts View File

@@ -0,0 +1,3 @@
export const serialize = JSON.stringify

export const deserialize = JSON.parse

+ 35
- 0
tsconfig.json View File

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

+ 6112
- 0
yarn.lock
File diff suppressed because it is too large
View File


+ 9
- 0
zeichen.config.ts View File

@@ -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', }),
]
}

Loading…
Cancel
Save