Monorepo containing core modules of Zeichen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

507 line
13 KiB

  1. import * as React from 'react'
  2. import Head from 'next/head'
  3. import styled from 'styled-components'
  4. import Editor from '../components/Editor/Editor'
  5. import * as Storage from '../services/Storage'
  6. import generateId from '../utilities/Id'
  7. import Link from 'next/link'
  8. import { formatDate } from '../utilities/Date'
  9. import { useRouter } from 'next/router'
  10. import { Trash2, FilePlus, FolderPlus, FileText, GitBranch, User } from 'react-feather'
  11. const Navbar = styled('aside')({
  12. width: 360,
  13. height: '100%',
  14. position: 'fixed',
  15. top: 0,
  16. left: -360,
  17. backgroundColor: 'yellow',
  18. '@media (min-width: 1080px)': {
  19. width: `${100 / 3}%`,
  20. left: 0,
  21. },
  22. })
  23. const PrimaryNavItems = styled('nav')({
  24. height: '100%',
  25. display: 'flex',
  26. flexDirection: 'column',
  27. justifyContent: 'space-between',
  28. alignItems: 'stretch',
  29. '@media (min-width: 1080px)': {
  30. width: '4rem',
  31. },
  32. })
  33. const SecondaryNavItems = styled('nav')({
  34. height: '100%',
  35. position: 'relative',
  36. '::before': {
  37. content: "''",
  38. display: 'block',
  39. top: 0,
  40. left: 0,
  41. width: '100%',
  42. height: '100%',
  43. position: 'absolute',
  44. zIndex: -1,
  45. backgroundColor: 'white',
  46. opacity: 0.5,
  47. },
  48. '@media (min-width: 1080px)': {
  49. flex: 'auto',
  50. },
  51. })
  52. const SecondaryNavItemsOverflow = styled('div')({
  53. overflow: 'auto',
  54. width: '100%',
  55. height: '100%',
  56. })
  57. const Main = styled('main')({
  58. margin: '2rem 0',
  59. '@media (min-width: 1080px)': {
  60. paddingLeft: `${100 / 3}%`,
  61. boxSizing: 'border-box',
  62. },
  63. })
  64. const Container = styled('div')({
  65. width: '100%',
  66. margin: '0 auto',
  67. padding: '0 1rem',
  68. boxSizing: 'border-box',
  69. '@media (min-width: 720px)': {
  70. maxWidth: 720,
  71. },
  72. })
  73. const NavbarItems = styled('div')({
  74. display: 'flex',
  75. width: '100%',
  76. height: '100%',
  77. })
  78. const NavbarContainer = styled('div')({
  79. display: 'block',
  80. width: '100%',
  81. height: '100%',
  82. margin: '0 0 0 auto',
  83. boxSizing: 'border-box',
  84. maxWidth: 360,
  85. })
  86. const TitleInput = styled('input')({
  87. border: 0,
  88. background: 'transparent',
  89. padding: 0,
  90. display: 'block',
  91. width: '100%',
  92. font: 'inherit',
  93. fontSize: '3rem',
  94. fontWeight: 'bold',
  95. outline: 0,
  96. })
  97. const NoteLink = styled('a')({
  98. display: 'flex',
  99. textDecoration: 'none',
  100. color: 'inherit',
  101. height: '4rem',
  102. alignItems: 'center',
  103. position: 'relative',
  104. })
  105. const NoteLinkPrimary = styled('div')({
  106. display: 'block',
  107. })
  108. const NoteLinkTitle = styled('strong')({
  109. verticalAlign: 'middle',
  110. })
  111. const LinkContainer = styled('div')({
  112. position: 'relative',
  113. })
  114. const NoteActions = styled('div')({
  115. display: 'flex',
  116. position: 'absolute',
  117. alignItems: 'stretch',
  118. top: 0,
  119. right: 0,
  120. height: '100%',
  121. })
  122. const NoteAction = styled('button')({
  123. height: '100%',
  124. width: '4rem',
  125. background: 'transparent',
  126. border: 0,
  127. color: 'inherit',
  128. cursor: 'pointer',
  129. outline: 0,
  130. })
  131. const NoteLinkBackground = styled('span')({
  132. opacity: 0.125,
  133. backgroundColor: 'currentColor',
  134. top: 0,
  135. left: 0,
  136. width: '100%',
  137. height: '100%',
  138. position: 'absolute',
  139. })
  140. const NewIcon = styled(FilePlus)({
  141. verticalAlign: 'middle',
  142. marginRight: '0.5rem',
  143. })
  144. const NewFolderIcon = styled(FolderPlus)({
  145. verticalAlign: 'middle',
  146. marginRight: '0.5rem',
  147. })
  148. const BinIcon = styled(Trash2)({
  149. verticalAlign: 'middle',
  150. marginRight: '0.5rem',
  151. })
  152. const NavbarItemContent = styled('span')({
  153. padding: '0 1rem',
  154. boxSizing: 'border-box',
  155. })
  156. const PrimaryNavItem = styled('a')({
  157. width: '4rem',
  158. height: '4rem',
  159. display: 'grid',
  160. placeContent: 'center',
  161. color: 'inherit',
  162. })
  163. const PostMeta = styled('small')({
  164. opacity: 0.5,
  165. height: '1.25rem',
  166. display: 'block',
  167. lineHeight: 1.25,
  168. })
  169. const PostPrimary = styled('div')({
  170. marginBottom: '2rem',
  171. })
  172. type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, }
  173. const Notes = ({ id: idProp }) => {
  174. const [id, setId, ] = React.useState(idProp)
  175. const [title, setTitle, ] = React.useState('')
  176. const [notes, setNotes, ] = React.useState(null)
  177. const [folders, setFolders, ] = React.useState(null)
  178. const stateRef = React.useRef<NoteInstance>({ id, title: '', updatedAt: new Date().toISOString(), })
  179. const timeoutRef = React.useRef<number>(null)
  180. const router = useRouter()
  181. const autoSave = () => {
  182. if (timeoutRef.current !== null) {
  183. window.clearTimeout(timeoutRef.current)
  184. }
  185. timeoutRef.current = window.setTimeout(async () => {
  186. const newNote = await Storage.saveNote({
  187. ...stateRef.current,
  188. updatedAt: (stateRef.current.updatedAt = new Date().toISOString()),
  189. })
  190. if (router.query.id !== id) {
  191. await router.push(`/notes/${id}`, undefined, { shallow: true })
  192. }
  193. setNotes(oldNotes => {
  194. let notes
  195. if (oldNotes.some((a) => a.id === id)) {
  196. notes = oldNotes.map(n => n.id === id ? newNote : n)
  197. } else {
  198. notes = [newNote, ...oldNotes]
  199. }
  200. return notes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
  201. })
  202. timeoutRef.current = null
  203. }, 3000)
  204. }
  205. const handleEditorChange = e => {
  206. stateRef.current.content = e
  207. autoSave()
  208. }
  209. const handleTitleChange = e => {
  210. stateRef.current.title = e.target.value
  211. setTitle(e.target.value)
  212. autoSave()
  213. }
  214. const deleteNote = note => async () => {
  215. setNotes(notes.filter(n => n.id !== note.id))
  216. const result = await Storage.deleteNote(note)
  217. if (!result) {
  218. setNotes(notes)
  219. }
  220. }
  221. React.useEffect(() => {
  222. const loadNotes = async () => {
  223. const theNotes = await Storage.loadNotes()
  224. setNotes(theNotes)
  225. }
  226. loadNotes()
  227. }, [])
  228. React.useEffect(() => {
  229. const loadFolders = async () => {
  230. const theFolders = await Storage.loadFolders()
  231. setFolders(theFolders)
  232. }
  233. loadFolders()
  234. }, [])
  235. React.useEffect(() => {
  236. if (!Array.isArray(notes!)) {
  237. return
  238. }
  239. const theNote = notes.find(n => n.id === id)
  240. stateRef.current = theNote ? theNote : { id, title: '', updatedAt: new Date().toISOString(), }
  241. setTitle(stateRef.current.title)
  242. }, [id, notes])
  243. React.useEffect(() => {
  244. setId(idProp || generateId())
  245. }, [idProp])
  246. return (
  247. <React.Fragment>
  248. <Head>
  249. <title>{ idProp === undefined ? 'Notes | New Note' : `Notes | ${title.length > 0 ? title : '(untitled)'}`}</title>
  250. <link rel="icon" href="/favicon.ico" />
  251. </Head>
  252. <Navbar>
  253. <NavbarContainer>
  254. <NavbarItems>
  255. <PrimaryNavItems>
  256. <div>
  257. <Link
  258. href={{
  259. pathname: '/notes',
  260. }}
  261. passHref
  262. >
  263. <PrimaryNavItem>
  264. <FileText />
  265. </PrimaryNavItem>
  266. </Link>
  267. <Link
  268. href={{
  269. pathname: '/graph',
  270. }}
  271. passHref
  272. >
  273. <PrimaryNavItem>
  274. <GitBranch />
  275. </PrimaryNavItem>
  276. </Link>
  277. </div>
  278. <div>
  279. <Link
  280. href={{
  281. pathname: '/me',
  282. }}
  283. passHref
  284. >
  285. <PrimaryNavItem>
  286. <User />
  287. </PrimaryNavItem>
  288. </Link>
  289. </div>
  290. </PrimaryNavItems>
  291. <SecondaryNavItems>
  292. <SecondaryNavItemsOverflow>
  293. <Link
  294. href={{
  295. pathname: '/profile',
  296. }}
  297. passHref
  298. >
  299. <NoteLink>
  300. <NavbarItemContent>
  301. <NoteLinkPrimary>
  302. <NewFolderIcon />
  303. <NoteLinkTitle>
  304. Personal
  305. </NoteLinkTitle>
  306. </NoteLinkPrimary>
  307. </NavbarItemContent>
  308. </NoteLink>
  309. </Link>
  310. <Link
  311. href={{
  312. pathname: '/folders/new',
  313. }}
  314. passHref
  315. >
  316. <NoteLink>
  317. <NavbarItemContent>
  318. <NoteLinkPrimary>
  319. <NewFolderIcon />
  320. <NoteLinkTitle>
  321. Create Folder
  322. </NoteLinkTitle>
  323. </NoteLinkPrimary>
  324. </NavbarItemContent>
  325. </NoteLink>
  326. </Link>
  327. <Link
  328. href={{
  329. pathname: '/notes',
  330. }}
  331. passHref
  332. >
  333. <NoteLink>
  334. <NavbarItemContent>
  335. <NoteLinkPrimary>
  336. <NewIcon />
  337. <NoteLinkTitle>
  338. Create Note
  339. </NoteLinkTitle>
  340. </NoteLinkPrimary>
  341. </NavbarItemContent>
  342. </NoteLink>
  343. </Link>
  344. {
  345. Array.isArray(notes!)
  346. && notes.map(n => (
  347. <LinkContainer
  348. key={n.id}
  349. >
  350. {
  351. n.id === id
  352. && (
  353. <NoteLinkBackground />
  354. )
  355. }
  356. <Link
  357. href={{
  358. pathname: '/notes/[id]',
  359. query: { id: n.id },
  360. }}
  361. replace
  362. passHref
  363. >
  364. <NoteLink>
  365. <NavbarItemContent>
  366. <NoteLinkPrimary>
  367. <NoteLinkTitle
  368. style={{ opacity: n.title.length > 0 ? 1 : 0.5, }}
  369. >
  370. {n.title.length > 0 ? n.title : '(untitled)'}
  371. </NoteLinkTitle>
  372. </NoteLinkPrimary>
  373. {' '}
  374. <PostMeta>
  375. <time
  376. dateTime={new Date(n.updatedAt).toISOString()}
  377. >
  378. Last updated {formatDate(new Date(n.updatedAt))}
  379. </time>
  380. </PostMeta>
  381. </NavbarItemContent>
  382. </NoteLink>
  383. </Link>
  384. <NoteActions>
  385. <NoteAction
  386. onClick={deleteNote(n)}
  387. >
  388. <Trash2 />
  389. </NoteAction>
  390. </NoteActions>
  391. </LinkContainer>
  392. ))
  393. }
  394. <Link
  395. href={{
  396. pathname: '/bin',
  397. }}
  398. passHref
  399. >
  400. <NoteLink>
  401. <NavbarItemContent>
  402. <NoteLinkPrimary>
  403. <BinIcon />
  404. <NoteLinkTitle>
  405. View Binned Notes
  406. </NoteLinkTitle>
  407. </NoteLinkPrimary>
  408. </NavbarItemContent>
  409. </NoteLink>
  410. </Link>
  411. </SecondaryNavItemsOverflow>
  412. </SecondaryNavItems>
  413. </NavbarItems>
  414. </NavbarContainer>
  415. </Navbar>
  416. <Main>
  417. <Container>
  418. {
  419. Array.isArray(notes!)
  420. && (
  421. <React.Fragment>
  422. <PostPrimary>
  423. <TitleInput
  424. placeholder="Title"
  425. value={title}
  426. onChange={handleTitleChange}
  427. />
  428. <PostMeta>
  429. {
  430. stateRef.current.updatedAt
  431. && router.query.id
  432. && (
  433. <time
  434. dateTime={new Date(stateRef.current.updatedAt).toISOString()}
  435. >
  436. Last updated {formatDate(new Date(stateRef.current.updatedAt))}
  437. </time>
  438. )
  439. }
  440. </PostMeta>
  441. </PostPrimary>
  442. <Editor
  443. autoFocus={false}
  444. key={id}
  445. content={stateRef.current ? stateRef.current.content : undefined}
  446. onChange={handleEditorChange}
  447. placeholder="Start typing here."
  448. />
  449. </React.Fragment>
  450. )
  451. }
  452. </Container>
  453. </Main>
  454. </React.Fragment>
  455. )
  456. }
  457. export const getServerSideProps = async ctx => {
  458. if (ctx.params) {
  459. return {
  460. props: {
  461. id: ctx.params?.id
  462. }
  463. }
  464. }
  465. return {
  466. props: {}
  467. }
  468. }
  469. export default Notes