From 91414da0ac9bb9a0dcf12e8377a634f5db999ab8 Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Tue, 7 Jan 2020 11:42:48 +1100 Subject: [PATCH 001/137] Digests on web (#380) * Implement operations * Implement digest endpoints * Implement digests reducer and basic page * Make note component reusable * Implement digest page * Add license * Fix style and accessbility * Fix loading * Fix query * Test * Remove debug --- jslib/src/helpers/arr.spec.ts | 42 ++ jslib/src/helpers/arr.ts | 29 ++ jslib/src/helpers/http.ts | 9 +- jslib/src/operations/digests.ts | 34 ++ .../src/operations/docs.ts | 11 +- jslib/src/operations/index.ts | 5 +- jslib/src/operations/notes.ts | 3 - jslib/src/operations/types.ts | 25 +- jslib/src/services/digests.ts | 58 ++- jslib/src/services/index.ts | 5 +- jslib/src/services/noteReviews.ts | 56 +++ jslib/src/services/notes.ts | 89 +++- jslib/src/services/repetitionRules.ts | 42 +- pkg/server/app/digests.go | 181 +++++++ pkg/server/app/digests_test.go | 54 +++ pkg/server/app/doc.go | 22 + pkg/server/app/notes.go | 35 +- pkg/server/app/notes_test.go | 145 ++---- pkg/server/database/database.go | 3 + ...20191225185502-populate-digest-version.sql | 19 + ...191226093447-add-digest-id-primary-key.sql | 10 + ...9-use-id-in-digest-notes-joining-table.sql | 49 ++ ...20191226152111-delete-outdated-digests.sql | 15 + pkg/server/database/models.go | 65 ++- pkg/server/handlers/digests.go | 157 ++++++ pkg/server/handlers/digests_test.go | 132 +++++ pkg/server/handlers/note_review.go | 150 ++++++ pkg/server/handlers/note_review_test.go | 113 +++++ pkg/server/handlers/notes.go | 4 +- pkg/server/handlers/notes_test.go | 4 - pkg/server/handlers/routes.go | 4 + pkg/server/job/repetition/repetition.go | 22 +- pkg/server/job/repetition/strategy.go | 8 +- pkg/server/mailer/mailer.go | 10 +- pkg/server/mailer/templates/.env.dev | 5 +- pkg/server/mailer/templates/src/digest.html | 451 ------------------ pkg/server/mailer/templates/src/digest.txt | 15 + pkg/server/mailer/templates/src/footer.html | 12 - pkg/server/mailer/templates/src/header.html | 31 -- pkg/server/mailer/types.go | 6 +- pkg/server/operations/digests.go | 45 ++ pkg/server/operations/digests_test.go | 68 +++ pkg/server/operations/doc.go | 23 + pkg/server/operations/main_test.go | 35 ++ pkg/server/operations/notes.go | 53 ++ pkg/server/operations/notes_test.go | 151 ++++++ pkg/server/presenters/digest.go | 61 ++- pkg/server/presenters/digest_receipt.go | 53 ++ pkg/server/testutils/main.go | 15 +- pkg/server/tmpl/data.go | 4 +- web/assets/index.html | 3 - web/src/components/App/App.scss | 2 +- web/src/components/App/_buttons.scss | 4 + web/src/components/App/_shared.scss | 27 +- web/src/components/App/_theme.scss | 8 +- ...BookHolder.module.scss => BookHolder.scss} | 0 .../Books/{BookHolder.js => BookHolder.tsx} | 2 +- web/src/components/Books/BookList.scss | 1 + web/src/components/Books/BookList.tsx | 2 +- web/src/components/Common/Auth.scss | 2 +- web/src/components/Common/Button/index.tsx | 2 +- web/src/components/Common/Menu/Menu.scss | 5 + web/src/components/Common/Menu/index.tsx | 26 +- web/src/components/Common/MobileMenu.scss | 4 + web/src/components/Common/MobileMenu.tsx | 15 +- web/src/components/Common/MultiSelect.scss | 2 +- web/src/components/Common/Note/Content.tsx | 90 ++++ web/src/components/Common/Note/Footer.tsx | 75 +++ .../Content.scss => Common/Note/Note.scss} | 92 ++-- .../{ => Common}/Note/Placeholder.scss | 8 +- .../{ => Common}/Note/Placeholder.tsx | 12 +- web/src/components/Common/Note/index.tsx | 63 +++ .../PageToolbar/Paginator/PageLink.tsx} | 73 +-- .../PageToolbar/Paginator}/Paginator.scss | 8 +- .../Common/PageToolbar/Paginator/index.tsx | 68 +++ .../Common/PageToolbar/SelectMenu.scss | 79 +++ .../Common/PageToolbar/SelectMenu.tsx | 90 ++++ .../PageToolbar/index.scss} | 2 - .../Top.tsx => Common/PageToolbar/index.tsx} | 16 +- web/src/components/Digest/ClearSearchBar.scss | 43 ++ web/src/components/Digest/ClearSearchBar.tsx | 54 +++ web/src/components/Digest/Digest.scss | 69 +++ web/src/components/Digest/Header/Content.scss | 70 +++ web/src/components/Digest/Header/Content.tsx | 86 ++++ .../components/Digest/Header/Placeholder.scss | 49 ++ .../components/Digest/Header/Placeholder.tsx | 36 ++ .../components/Digest/Header/Progress.scss | 75 +++ web/src/components/Digest/Header/Progress.tsx | 74 +++ web/src/components/Digest/Header/index.tsx | 71 +++ .../components/Digest/NoteItem/Header.scss | 71 +++ web/src/components/Digest/NoteItem/Header.tsx | 95 ++++ .../Digest/NoteItem/ReviewButton.scss | 39 ++ .../Digest/NoteItem/ReviewButton.tsx | 71 +++ web/src/components/Digest/NoteItem/index.tsx | 74 +++ web/src/components/Digest/NoteList.tsx | 86 ++++ .../components/Digest/Toolbar/SortMenu.tsx | 121 +++++ .../components/Digest/Toolbar/StatusMenu.tsx | 144 ++++++ .../components/Digest/Toolbar/Toolbar.scss | 47 ++ web/src/components/Digest/Toolbar/index.tsx | 53 ++ web/src/components/Digest/helpers.ts | 24 + web/src/components/Digest/index.tsx | 165 +++++++ web/src/components/Digest/types.ts | 36 ++ web/src/components/Digests/Item.scss | 67 +++ web/src/components/Digests/Item.tsx | 62 +++ web/src/components/Digests/List.scss | 31 ++ web/src/components/Digests/List.tsx | 61 +++ web/src/components/Digests/Placeholder.scss | 33 ++ web/src/components/Digests/Placeholder.tsx | 31 ++ .../components/Digests/Toolbar/StatusMenu.tsx | 123 +++++ .../components/Digests/Toolbar/Toolbar.scss | 33 ++ web/src/components/Digests/Toolbar/index.tsx | 53 ++ web/src/components/Digests/index.tsx | 91 ++++ web/src/components/Digests/types.tsx | 23 + .../EmailPreference/EmailPreference.scss | 2 +- web/src/components/Header/AccountMenu.scss | 6 +- web/src/components/Header/Nav/index.tsx | 9 +- web/src/components/Header/Note/Guest.scss | 2 +- .../components/Header/Note/Placeholder.scss | 2 +- web/src/components/Header/Note/index.scss | 2 +- .../Header/SearchBar/SearchBar.scss | 3 +- web/src/components/Home/Home.scss | 3 +- .../components/Home/NoteGroup/NoteItem.tsx | 6 +- web/src/components/Home/index.tsx | 25 +- web/src/components/Note/Content.tsx | 198 -------- web/src/components/Note/FooterActions.scss | 39 ++ web/src/components/Note/FooterActions.tsx | 82 ++++ web/src/components/Note/Header.scss | 37 ++ web/src/components/Note/Header.tsx | 75 +++ web/src/components/Note/HeaderData.tsx | 2 +- web/src/components/Note/HeaderRight.tsx | 56 +++ web/src/components/Note/index.scss | 2 +- web/src/components/Note/index.tsx | 34 +- .../EmailPreferenceRepetition.scss | 2 +- web/src/components/Repetition/Content.tsx | 17 +- web/src/components/Repetition/Repetition.scss | 4 + .../RepetitionItem/Placeholder.scss | 44 ++ .../Repetition/RepetitionItem/Placeholder.tsx | 37 ++ .../components/Repetition/RepetitionList.tsx | 17 +- web/src/components/Settings/Sidebar.scss | 2 + web/src/components/Splash/Splash.module.scss | 2 +- web/src/components/TabBar/index.tsx | 47 +- web/src/libs/dom.ts | 4 + web/src/libs/notes.ts | 9 +- web/src/libs/paths.ts | 15 + web/src/libs/string.ts | 19 +- web/src/routes.tsx | 16 +- web/src/store/digest/actions.ts | 115 +++++ web/src/store/digest/index.ts | 21 + web/src/store/digest/reducers.ts | 113 +++++ web/src/store/digest/type.ts | 65 +++ web/src/store/digests/actions.ts | 67 +++ web/src/store/digests/index.ts | 21 + web/src/store/digests/reducers.ts | 72 +++ web/src/store/digests/type.ts | 61 +++ web/src/store/index.ts | 4 + web/src/store/note/reducers.ts | 12 +- web/src/store/types.ts | 5 + 157 files changed, 5850 insertions(+), 1206 deletions(-) create mode 100644 jslib/src/helpers/arr.spec.ts create mode 100644 jslib/src/helpers/arr.ts create mode 100644 jslib/src/operations/digests.ts rename web/src/components/Note/Actions.tsx => jslib/src/operations/docs.ts (82%) create mode 100644 jslib/src/services/noteReviews.ts create mode 100644 pkg/server/app/digests.go create mode 100644 pkg/server/app/digests_test.go create mode 100644 pkg/server/app/doc.go create mode 100644 pkg/server/database/migrations/20191225185502-populate-digest-version.sql create mode 100644 pkg/server/database/migrations/20191226093447-add-digest-id-primary-key.sql create mode 100644 pkg/server/database/migrations/20191226105659-use-id-in-digest-notes-joining-table.sql create mode 100644 pkg/server/database/migrations/20191226152111-delete-outdated-digests.sql create mode 100644 pkg/server/handlers/digests.go create mode 100644 pkg/server/handlers/digests_test.go create mode 100644 pkg/server/handlers/note_review.go create mode 100644 pkg/server/handlers/note_review_test.go delete mode 100644 pkg/server/mailer/templates/src/digest.html create mode 100644 pkg/server/mailer/templates/src/digest.txt delete mode 100644 pkg/server/mailer/templates/src/footer.html delete mode 100644 pkg/server/mailer/templates/src/header.html create mode 100644 pkg/server/operations/digests.go create mode 100644 pkg/server/operations/digests_test.go create mode 100644 pkg/server/operations/doc.go create mode 100644 pkg/server/operations/main_test.go create mode 100644 pkg/server/operations/notes.go create mode 100644 pkg/server/operations/notes_test.go create mode 100644 pkg/server/presenters/digest_receipt.go rename web/src/components/Books/{BookHolder.module.scss => BookHolder.scss} (100%) rename web/src/components/Books/{BookHolder.js => BookHolder.tsx} (95%) create mode 100644 web/src/components/Common/Note/Content.tsx create mode 100644 web/src/components/Common/Note/Footer.tsx rename web/src/components/{Note/Content.scss => Common/Note/Note.scss} (70%) rename web/src/components/{ => Common}/Note/Placeholder.scss (92%) rename web/src/components/{ => Common}/Note/Placeholder.tsx (82%) create mode 100644 web/src/components/Common/Note/index.tsx rename web/src/components/{Home/Actions/Paginator.tsx => Common/PageToolbar/Paginator/PageLink.tsx} (57%) rename web/src/components/{Home/Actions => Common/PageToolbar/Paginator}/Paginator.scss (90%) create mode 100644 web/src/components/Common/PageToolbar/Paginator/index.tsx create mode 100644 web/src/components/Common/PageToolbar/SelectMenu.scss create mode 100644 web/src/components/Common/PageToolbar/SelectMenu.tsx rename web/src/components/{Home/Actions/Top.scss => Common/PageToolbar/index.scss} (97%) rename web/src/components/{Home/Actions/Top.tsx => Common/PageToolbar/index.tsx} (77%) create mode 100644 web/src/components/Digest/ClearSearchBar.scss create mode 100644 web/src/components/Digest/ClearSearchBar.tsx create mode 100644 web/src/components/Digest/Digest.scss create mode 100644 web/src/components/Digest/Header/Content.scss create mode 100644 web/src/components/Digest/Header/Content.tsx create mode 100644 web/src/components/Digest/Header/Placeholder.scss create mode 100644 web/src/components/Digest/Header/Placeholder.tsx create mode 100644 web/src/components/Digest/Header/Progress.scss create mode 100644 web/src/components/Digest/Header/Progress.tsx create mode 100644 web/src/components/Digest/Header/index.tsx create mode 100644 web/src/components/Digest/NoteItem/Header.scss create mode 100644 web/src/components/Digest/NoteItem/Header.tsx create mode 100644 web/src/components/Digest/NoteItem/ReviewButton.scss create mode 100644 web/src/components/Digest/NoteItem/ReviewButton.tsx create mode 100644 web/src/components/Digest/NoteItem/index.tsx create mode 100644 web/src/components/Digest/NoteList.tsx create mode 100644 web/src/components/Digest/Toolbar/SortMenu.tsx create mode 100644 web/src/components/Digest/Toolbar/StatusMenu.tsx create mode 100644 web/src/components/Digest/Toolbar/Toolbar.scss create mode 100644 web/src/components/Digest/Toolbar/index.tsx create mode 100644 web/src/components/Digest/helpers.ts create mode 100644 web/src/components/Digest/index.tsx create mode 100644 web/src/components/Digest/types.ts create mode 100644 web/src/components/Digests/Item.scss create mode 100644 web/src/components/Digests/Item.tsx create mode 100644 web/src/components/Digests/List.scss create mode 100644 web/src/components/Digests/List.tsx create mode 100644 web/src/components/Digests/Placeholder.scss create mode 100644 web/src/components/Digests/Placeholder.tsx create mode 100644 web/src/components/Digests/Toolbar/StatusMenu.tsx create mode 100644 web/src/components/Digests/Toolbar/Toolbar.scss create mode 100644 web/src/components/Digests/Toolbar/index.tsx create mode 100644 web/src/components/Digests/index.tsx create mode 100644 web/src/components/Digests/types.tsx delete mode 100644 web/src/components/Note/Content.tsx create mode 100644 web/src/components/Note/FooterActions.scss create mode 100644 web/src/components/Note/FooterActions.tsx create mode 100644 web/src/components/Note/Header.scss create mode 100644 web/src/components/Note/Header.tsx create mode 100644 web/src/components/Note/HeaderRight.tsx create mode 100644 web/src/components/Repetition/RepetitionItem/Placeholder.scss create mode 100644 web/src/components/Repetition/RepetitionItem/Placeholder.tsx create mode 100644 web/src/store/digest/actions.ts create mode 100644 web/src/store/digest/index.ts create mode 100644 web/src/store/digest/reducers.ts create mode 100644 web/src/store/digest/type.ts create mode 100644 web/src/store/digests/actions.ts create mode 100644 web/src/store/digests/index.ts create mode 100644 web/src/store/digests/reducers.ts create mode 100644 web/src/store/digests/type.ts diff --git a/jslib/src/helpers/arr.spec.ts b/jslib/src/helpers/arr.spec.ts new file mode 100644 index 00000000..8cbd9d5e --- /dev/null +++ b/jslib/src/helpers/arr.spec.ts @@ -0,0 +1,42 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import { getRange } from './arr'; + +describe('getRange', () => { + const testCases = [ + { + input: 1, + expected: [1] + }, + { + input: 3, + expected: [1, 2, 3] + } + ]; + + for (let i = 0; i < testCases.length; ++i) { + const tc = testCases[i]; + + test(`generates a range for ${tc.input}`, () => { + const result = getRange(tc.input); + + expect(result).toStrictEqual(tc.expected); + }); + } +}); diff --git a/jslib/src/helpers/arr.ts b/jslib/src/helpers/arr.ts new file mode 100644 index 00000000..8e9654a2 --- /dev/null +++ b/jslib/src/helpers/arr.ts @@ -0,0 +1,29 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +// // getRange returns an array with an incrementing integers from +// 1 to the given number. e.g. [1, 2, 3, ..., 10] +export function getRange(n: number): number[] { + const ret = []; + + for (let i = 1; i <= n; i++) { + ret.push(i); + } + + return ret; +} diff --git a/jslib/src/helpers/http.ts b/jslib/src/helpers/http.ts index c2912c2c..72324ecc 100644 --- a/jslib/src/helpers/http.ts +++ b/jslib/src/helpers/http.ts @@ -84,9 +84,10 @@ function put(path: string, data: any, options = {}) { }); } -function del(path: string, options = {}) { - return request(path, { +function del(path: string, data: any, options = {}) { + return request(path, { method: 'DELETE', + body: JSON.stringify(data), ...options }); } @@ -127,8 +128,8 @@ export function getHttpClient(c: HttpClientConfig) { put: (path: string, data, options = {}) => { return put(transformPath(path), data, options); }, - del: (path: string, options = {}) => { - return del(transformPath(path), options); + del: (path: string, data = {}, options = {}) => { + return del(transformPath(path), data, options); } }; } diff --git a/jslib/src/operations/digests.ts b/jslib/src/operations/digests.ts new file mode 100644 index 00000000..465ed40d --- /dev/null +++ b/jslib/src/operations/digests.ts @@ -0,0 +1,34 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import initDigestsService from '../services/digests'; +import { HttpClientConfig } from '../helpers/http'; + +export default function init(c: HttpClientConfig) { + const digestsService = initDigestsService(c); + + return { + fetchAll: params => { + return digestsService.fetchAll(params); + }, + + fetch: (noteUUID: string) => { + return digestsService.fetch(noteUUID); + } + }; +} diff --git a/web/src/components/Note/Actions.tsx b/jslib/src/operations/docs.ts similarity index 82% rename from web/src/components/Note/Actions.tsx rename to jslib/src/operations/docs.ts index f0b53f33..301e84ee 100644 --- a/web/src/components/Note/Actions.tsx +++ b/jslib/src/operations/docs.ts @@ -16,12 +16,5 @@ * along with Dnote. If not, see . */ -import React from 'react'; - -interface Props {} - -const Actions: React.FunctionComponent = () => { - return
Actions
; -}; - -export default Actions; +// This module provides interfaces to perform operations. It abstarcts +// the backend implementation and thus unifies the API for different clients. diff --git a/jslib/src/operations/index.ts b/jslib/src/operations/index.ts index dde08a6c..1720f04a 100644 --- a/jslib/src/operations/index.ts +++ b/jslib/src/operations/index.ts @@ -19,15 +19,18 @@ import { HttpClientConfig } from '../helpers/http'; import initBooksOperation from './books'; import initNotesOperation from './notes'; +import initDigestsOperation from './digests'; // init initializes operations with the given http configuration // and returns an object of all services. export default function initOperations(c: HttpClientConfig) { const booksOperation = initBooksOperation(c); const notesOperation = initNotesOperation(c); + const digestsOperation = initDigestsOperation(c); return { books: booksOperation, - notes: notesOperation + notes: notesOperation, + digests: digestsOperation }; } diff --git a/jslib/src/operations/notes.ts b/jslib/src/operations/notes.ts index ab4e8461..50fdb409 100644 --- a/jslib/src/operations/notes.ts +++ b/jslib/src/operations/notes.ts @@ -16,9 +16,6 @@ * along with Dnote. If not, see . */ -// This module provides interfaces to perform operations. It abstarcts -// the backend implementation and thus unifies the API for web and desktop clients. - import initNotesService from '../services/notes'; import { HttpClientConfig } from '../helpers/http'; import { NoteData } from './types'; diff --git a/jslib/src/operations/types.ts b/jslib/src/operations/types.ts index 0272a5f3..9be61b34 100644 --- a/jslib/src/operations/types.ts +++ b/jslib/src/operations/types.ts @@ -20,10 +20,10 @@ // The response from services need to conform to this interface. export interface NoteData { uuid: string; - created_at: string; - updated_at: string; + createdAt: string; + updatedAt: string; content: string; - added_on: number; + addedOn: number; public: boolean; usn: number; book: { @@ -83,3 +83,22 @@ export interface RepetitionRuleData { createdAt: string; updatedAt: string; } + +export interface ReceiptData { + createdAt: string; + updatedAt: string; +} + +export interface DigestData { + uuid: string; + createdAt: string; + updatedAt: string; + version: number; + notes: DigestNoteData[]; + isRead: boolean; + repetitionRule: RepetitionRuleData; +} + +export interface DigestNoteData extends NoteData { + isReviewed: boolean; +} diff --git a/jslib/src/services/digests.ts b/jslib/src/services/digests.ts index bc9249d1..958c9a13 100644 --- a/jslib/src/services/digests.ts +++ b/jslib/src/services/digests.ts @@ -18,23 +18,71 @@ import { getHttpClient, HttpClientConfig } from '../helpers/http'; import { getPath } from '../helpers/url'; +import { DigestData, DigestNoteData } from '../operations/types'; +import { mapNote } from './notes'; + +function mapDigestNote(item): DigestNoteData { + const note = mapNote(item); + + return { + ...note, + isReviewed: item.is_reviewed + }; +} + +// mapDigest maps the presented digest response to DigestData +function mapDigest(item): DigestData { + return { + uuid: item.uuid, + createdAt: item.created_at, + updatedAt: item.updated_at, + version: item.version, + notes: item.notes.map(mapDigestNote), + repetitionRule: { + uuid: item.repetition_rule.uuid, + title: item.repetition_rule.title, + enabled: item.repetition_rule.enabled, + hour: item.repetition_rule.hour, + minute: item.repetition_rule.minute, + bookDomain: item.repetition_rule.book_domain, + frequency: item.repetition_rule.frequency, + books: item.repetition_rule.books, + lastActive: item.repetition_rule.last_active, + nextActive: item.repetition_rule.next_active, + noteCount: item.repetition_rule.note_count, + createdAt: item.repetition_rule.created_at, + updatedAt: item.repetition_rule.updated_at + }, + isRead: item.is_read + }; +} + +export interface FetchAllResult { + total: number; + items: DigestData[]; +} export default function init(config: HttpClientConfig) { const client = getHttpClient(config); return { - fetch: digestUUID => { + fetch: (digestUUID: string): Promise => { const endpoint = `/digests/${digestUUID}`; - return client.get(endpoint); + return client.get(endpoint).then(mapDigest); }, - fetchAll: ({ page }) => { + fetchAll: ({ page, status }): Promise => { const path = '/digests'; - const endpoint = getPath(path, { page }); + const endpoint = getPath(path, { page, status }); - return client.get(endpoint); + return client.get(endpoint).then(res => { + return { + total: res.total, + items: res.items.map(mapDigest) + }; + }); } }; } diff --git a/jslib/src/services/index.ts b/jslib/src/services/index.ts index f342a95a..ca2b6576 100644 --- a/jslib/src/services/index.ts +++ b/jslib/src/services/index.ts @@ -23,6 +23,7 @@ import initNotesService from './notes'; import initPaymentService from './payment'; import initDigestsService from './digests'; import initRepetitionRulesService from './repetitionRules'; +import initNoteReviews from './noteReviews'; // init initializes service helpers with the given http configuration // and returns an object of all services. @@ -33,6 +34,7 @@ export default function initServices(c: HttpClientConfig) { const paymentService = initPaymentService(c); const digestsService = initDigestsService(c); const repetitionRulesService = initRepetitionRulesService(c); + const noteReviewsService = initNoteReviews(c); return { users: usersService, @@ -40,6 +42,7 @@ export default function initServices(c: HttpClientConfig) { notes: notesService, payment: paymentService, digests: digestsService, - repetitionRules: repetitionRulesService + repetitionRules: repetitionRulesService, + noteReviews: noteReviewsService }; } diff --git a/jslib/src/services/noteReviews.ts b/jslib/src/services/noteReviews.ts new file mode 100644 index 00000000..f08d27d3 --- /dev/null +++ b/jslib/src/services/noteReviews.ts @@ -0,0 +1,56 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import { getHttpClient, HttpClientConfig } from '../helpers/http'; + +export interface CreateDeleteNoteReviewPayload { + digestUUID: string; + noteUUID: string; +} + +export default function init(config: HttpClientConfig) { + const client = getHttpClient(config); + + return { + create: ({ + digestUUID, + noteUUID + }: CreateDeleteNoteReviewPayload): Promise => { + const endpoint = '/note_review'; + const payload = { + digest_uuid: digestUUID, + note_uuid: noteUUID + }; + + return client.post(endpoint, payload); + }, + + remove: ({ + digestUUID, + noteUUID + }: CreateDeleteNoteReviewPayload): Promise => { + const endpoint = '/note_review'; + const payload = { + digest_uuid: digestUUID, + note_uuid: noteUUID + }; + + return client.del(endpoint, payload); + } + }; +} diff --git a/jslib/src/services/notes.ts b/jslib/src/services/notes.ts index a06772d4..dc2e77da 100644 --- a/jslib/src/services/notes.ts +++ b/jslib/src/services/notes.ts @@ -21,12 +21,54 @@ import { getHttpClient, HttpClientConfig } from '../helpers/http'; import { NoteData } from '../operations/types'; import { Filters } from '../helpers/filters'; +export interface PresentedNote { + uuid: string; + content: string; + updated_at: string; + created_at: string; + user: { + name: ''; + uuid: ''; + }; + public: boolean; + book: { + label: ''; + uuid: ''; + }; + usn: number; + added_on: number; +} + +export function mapNote(item: PresentedNote): NoteData { + return { + uuid: item.uuid, + content: item.content, + createdAt: item.created_at, + updatedAt: item.updated_at, + public: item.public, + user: { + name: item.user.name, + uuid: item.user.uuid + }, + book: { + label: item.book.label, + uuid: item.book.uuid + }, + usn: item.usn, + addedOn: item.added_on + }; +} + export interface CreateParams { book_uuid: string; content: string; } export interface CreateResponse { + result: PresentedNote; +} + +export interface CreateResult { result: NoteData; } @@ -36,12 +78,22 @@ export interface UpdateParams { public?: boolean; } -export interface UpdateNoteResp { +export interface UpdateResponse { + status: number; + result: PresentedNote; +} + +export interface UpdateResult { status: number; result: NoteData; } export interface FetchResponse { + notes: PresentedNote[]; + total: number; +} + +export interface FetchResult { notes: NoteData[]; total: number; } @@ -50,20 +102,32 @@ export interface FetchOneQuery { q?: string; } -type FetchOneResponse = NoteData; +type FetchOneResponse = PresentedNote; +type FetchOneResult = NoteData; export default function init(config: HttpClientConfig) { const client = getHttpClient(config); return { - create: (params: CreateParams, opts = {}): Promise => { - return client.post('/v3/notes', params, opts); + create: (params: CreateParams, opts = {}): Promise => { + return client + .post('/v3/notes', params, opts) + .then(res => { + return { + result: mapNote(res.result) + }; + }); }, - update: (noteUUID: string, params: UpdateParams) => { + update: (noteUUID: string, params: UpdateParams): Promise => { const endpoint = `/v3/notes/${noteUUID}`; - return client.patch(endpoint, params); + return client.patch(endpoint, params).then(res => { + return { + status: res.status, + result: mapNote(res.result) + }; + }); }, remove: (noteUUID: string) => { @@ -72,7 +136,7 @@ export default function init(config: HttpClientConfig) { return client.del(endpoint, {}); }, - fetch: (filters: Filters) => { + fetch: (filters: Filters): Promise => { const params: any = { page: filters.page }; @@ -87,16 +151,21 @@ export default function init(config: HttpClientConfig) { const endpoint = getPath('/notes', params); - return client.get(endpoint, {}); + return client.get(endpoint, {}).then(res => { + return { + total: res.total, + notes: res.notes.map(mapNote) + }; + }); }, fetchOne: ( noteUUID: string, params: FetchOneQuery - ): Promise => { + ): Promise => { const endpoint = getPath(`/notes/${noteUUID}`, params); - return client.get(endpoint, {}); + return client.get(endpoint, {}).then(mapNote); }, classicFetch: () => { diff --git a/jslib/src/services/repetitionRules.ts b/jslib/src/services/repetitionRules.ts index 79b89a3a..6d5bd809 100644 --- a/jslib/src/services/repetitionRules.ts +++ b/jslib/src/services/repetitionRules.ts @@ -16,12 +16,10 @@ * along with Dnote. If not, see . */ -import { BookData, RepetitionRuleData, BookDomain } from '../operations/types'; +import { RepetitionRuleData, BookDomain } from '../operations/types'; import { getHttpClient, HttpClientConfig } from '../helpers/http'; import { getPath } from '../helpers/url'; -export type FetchResponse = RepetitionRuleData[]; - export interface CreateParams { title: string; hour: number; @@ -35,23 +33,7 @@ export interface CreateParams { export type UpdateParams = Partial; -export interface RepetitionRuleRespData { - uuid: string; - title: string; - enabled: boolean; - hour: number; - minute: number; - book_domain: BookDomain; - frequency: number; - books: BookData[]; - note_count: number; - last_active: number; - next_active: number; - created_at: string; - updated_at: string; -} - -function mapData(d: RepetitionRuleRespData): RepetitionRuleData { +function mapData(d): RepetitionRuleData { return { uuid: d.uuid, title: d.title, @@ -77,35 +59,31 @@ export default function init(config: HttpClientConfig) { const path = `/repetition_rules/${uuid}`; const endpoint = getPath(path, queries); - return client.get(endpoint).then(resp => { + return client.get(endpoint).then(resp => { return mapData(resp); }); }, fetchAll: (): Promise => { const endpoint = '/repetition_rules'; - return client.get(endpoint).then(resp => { + return client.get(endpoint).then(resp => { return resp.map(mapData); }); }, create: (params: CreateParams) => { const endpoint = '/repetition_rules'; - return client - .post(endpoint, params) - .then(resp => { - return mapData(resp); - }); + return client.post(endpoint, params).then(resp => { + return mapData(resp); + }); }, update: (uuid: string, params: UpdateParams, queries = {}) => { const path = `/repetition_rules/${uuid}`; const endpoint = getPath(path, queries); - return client - .patch(endpoint, params) - .then(resp => { - return mapData(resp); - }); + return client.patch(endpoint, params).then(resp => { + return mapData(resp); + }); }, remove: (uuid: string) => { const endpoint = `/repetition_rules/${uuid}`; diff --git a/pkg/server/app/digests.go b/pkg/server/app/digests.go new file mode 100644 index 00000000..f10892b6 --- /dev/null +++ b/pkg/server/app/digests.go @@ -0,0 +1,181 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "fmt" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/pkg/errors" +) + +func (a *App) getExistingDigestReceipt(userID, digestID int) (*database.DigestReceipt, error) { + var ret database.DigestReceipt + conn := a.DB.Where("user_id = ? AND digest_id = ?", userID, digestID).First(&ret) + + if conn.RecordNotFound() { + return nil, nil + } + if err := conn.Error; err != nil { + return nil, errors.Wrap(err, "querying existing digest receipt") + } + + return &ret, nil +} + +// GetUserDigestByUUID retrives a digest by the uuid for the given user +func (a *App) GetUserDigestByUUID(userID int, uuid string) (*database.Digest, error) { + var ret database.Digest + conn := a.DB.Where("user_id = ? AND uuid = ?", userID, uuid).First(&ret) + + if conn.RecordNotFound() { + return nil, nil + } + if err := conn.Error; err != nil { + return nil, errors.Wrap(err, "finding digest") + } + + return &ret, nil +} + +// MarkDigestRead creates a new digest receipt. If one already exists for +// the given digest and the user, it is a noop. +func (a *App) MarkDigestRead(digest database.Digest, user database.User) (database.DigestReceipt, error) { + db := a.DB + + existing, err := a.getExistingDigestReceipt(user.ID, digest.ID) + if err != nil { + return database.DigestReceipt{}, errors.Wrap(err, "checking existing digest receipt") + } + if existing != nil { + return *existing, nil + } + + dat := database.DigestReceipt{ + UserID: user.ID, + DigestID: digest.ID, + } + if err := db.Create(&dat).Error; err != nil { + return database.DigestReceipt{}, errors.Wrap(err, "creating digest receipt") + } + + return dat, nil +} + +// GetDigestsParam is the params for getting a list of digests +type GetDigestsParam struct { + UserID int + Status string + Offset int + PerPage int + Order string +} + +func (p GetDigestsParam) getSubQuery() string { + orderClause := p.getOrderClause("digests") + + return fmt.Sprintf(`SELECT + digests.id AS digest_id, + digests.created_at AS created_at, + COUNT(digest_receipts.id) AS receipt_count +FROM digests +LEFT JOIN digest_receipts ON digest_receipts.digest_id = digests.id +WHERE digests.user_id = %d +GROUP BY digests.id, digests.created_at +%s`, p.UserID, orderClause) +} + +func (p GetDigestsParam) getSubQueryWhere() string { + var ret string + + if p.Status == "unread" { + ret = "WHERE t1.receipt_count = 0" + } else if p.Status == "read" { + ret = "WHERE t1.receipt_count > 0" + } + + return ret +} + +func (p GetDigestsParam) getOrderClause(table string) string { + if p.Order == "" { + return "" + } + + return fmt.Sprintf(`ORDER BY %s.%s`, table, p.Order) +} + +// CountDigests counts digests with the given user using the given criteria +func (a *App) CountDigests(p GetDigestsParam) (int, error) { + subquery := p.getSubQuery() + whereClause := p.getSubQueryWhere() + query := fmt.Sprintf(`SELECT COUNT(*) FROM (%s) AS t1 %s`, subquery, whereClause) + + result := struct { + Count int + }{} + if err := a.DB.Raw(query).Scan(&result).Error; err != nil { + return 0, errors.Wrap(err, "running count query") + } + + return result.Count, nil +} + +func (a *App) queryDigestIDs(p GetDigestsParam) ([]int, error) { + subquery := p.getSubQuery() + whereClause := p.getSubQueryWhere() + orderClause := p.getOrderClause("t1") + query := fmt.Sprintf(`SELECT t1.digest_id FROM (%s) AS t1 %s %s OFFSET ? LIMIT ?;`, subquery, whereClause, orderClause) + + ret := []int{} + rows, err := a.DB.Raw(query, p.Offset, p.PerPage).Rows() + if err != nil { + return nil, errors.Wrap(err, "getting rows") + } + defer rows.Close() + + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return []int{}, errors.Wrap(err, "scanning row") + } + + ret = append(ret, id) + } + + return ret, nil +} + +// GetDigests queries digests for the given user using the given criteria +func (a *App) GetDigests(p GetDigestsParam) ([]database.Digest, error) { + IDs, err := a.queryDigestIDs(p) + if err != nil { + return nil, errors.Wrap(err, "querying digest IDs") + } + + var ret []database.Digest + conn := a.DB.Where("id IN (?)", IDs). + Order(p.Order).Preload("Rule").Preload("Receipts"). + Find(&ret) + if err := conn.Error; err != nil && !conn.RecordNotFound() { + return nil, errors.Wrap(err, "finding digests") + } + + return ret, nil +} diff --git a/pkg/server/app/digests_test.go b/pkg/server/app/digests_test.go new file mode 100644 index 00000000..1b588cf6 --- /dev/null +++ b/pkg/server/app/digests_test.go @@ -0,0 +1,54 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMarkDigestRead(t *testing.T) { + defer testutils.ClearData() + + user := testutils.SetupUserData() + digest := database.Digest{UserID: user.ID} + testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest") + + a := NewTest(nil) + + // Multiple calls should not create more than 1 receipt + for i := 0; i < 3; i++ { + ret, err := a.MarkDigestRead(digest, user) + if err != nil { + t.Fatal(err, "failed to perform") + } + + var receiptCount int + testutils.MustExec(t, testutils.DB.Model(&database.DigestReceipt{}).Count(&receiptCount), "counting receipts") + assert.Equalf(t, receiptCount, 1, "receipt count mismatch") + + var receipt database.DigestReceipt + testutils.MustExec(t, testutils.DB.Where("id = ?", ret.ID).First(&receipt), "getting receipt") + assert.Equalf(t, receipt.UserID, user.ID, "receipt UserID mismatch") + assert.Equalf(t, receipt.DigestID, digest.ID, "receipt DigestID mismatch") + } +} diff --git a/pkg/server/app/doc.go b/pkg/server/app/doc.go new file mode 100644 index 00000000..344d9a0d --- /dev/null +++ b/pkg/server/app/doc.go @@ -0,0 +1,22 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +/* +Package app implements application business logic +*/ +package app diff --git a/pkg/server/app/notes.go b/pkg/server/app/notes.go index 1ba47322..c2cbf49d 100644 --- a/pkg/server/app/notes.go +++ b/pkg/server/app/notes.go @@ -21,7 +21,6 @@ package app import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/permissions" "github.com/jinzhu/gorm" "github.com/pkg/errors" ) @@ -157,31 +156,25 @@ func (a *App) DeleteNote(tx *gorm.DB, user database.User, note database.Note) (d return note, errors.Wrap(err, "deleting note") } + // Delete associations + if err := tx.Where("note_id = ?", note.ID).Delete(&database.DigestNote{}).Error; err != nil { + return note, errors.Wrap(err, "deleting digest_notes") + } + return note, nil } -// GetNote retrieves a note for the given user -func GetNote(db *gorm.DB, uuid string, user database.User) (database.Note, bool, error) { - zeroNote := database.Note{} - if !helpers.ValidateUUID(uuid) { - return zeroNote, false, nil - } - - conn := db.Where("notes.uuid = ? AND deleted = ?", uuid, false) - conn = database.PreloadNote(conn) - - var note database.Note - conn = conn.Find(¬e) +// GetUserNoteByUUID retrives a digest by the uuid for the given user +func (a *App) GetUserNoteByUUID(userID int, uuid string) (*database.Note, error) { + var ret database.Note + conn := a.DB.Where("user_id = ? AND uuid = ?", userID, uuid).First(&ret) if conn.RecordNotFound() { - return zeroNote, false, nil - } else if err := conn.Error; err != nil { - return zeroNote, false, errors.Wrap(err, "finding note") + return nil, nil + } + if err := conn.Error; err != nil { + return nil, errors.Wrap(err, "finding digest") } - if ok := permissions.ViewNote(&user, note); !ok { - return zeroNote, false, nil - } - - return note, true, nil + return &ret, nil } diff --git a/pkg/server/app/notes_test.go b/pkg/server/app/notes_test.go index 4a96c254..6395f509 100644 --- a/pkg/server/app/notes_test.go +++ b/pkg/server/app/notes_test.go @@ -260,127 +260,44 @@ func TestDeleteNote(t *testing.T) { } } -func TestGetNote(t *testing.T) { - user := testutils.SetupUserData() - anotherUser := testutils.SetupUserData() - +func TestDeleteNote_DigestNotes(t *testing.T) { defer testutils.ClearData() - b1 := database.Book{ - UserID: user.ID, - Label: "js", - } - testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") - - privateNote := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "privateNote content", - Deleted: false, - Public: false, - } - testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote") - - publicNote := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "privateNote content", - Deleted: false, - Public: true, - } - testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing privateNote") - - var privateNoteRecord, publicNoteRecord database.Note - testutils.MustExec(t, testutils.DB.Where("uuid = ?", privateNote.UUID).Preload("Book").Preload("User").First(&privateNoteRecord), "finding privateNote") - testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).Preload("Book").Preload("User").First(&publicNoteRecord), "finding publicNote") - - testCases := []struct { - name string - user database.User - note database.Note - expectedOK bool - expectedNote database.Note - }{ - { - name: "owner accessing private note", - user: user, - note: privateNote, - expectedOK: true, - expectedNote: privateNoteRecord, - }, - { - name: "non-owner accessing private note", - user: anotherUser, - note: privateNote, - expectedOK: false, - expectedNote: database.Note{}, - }, - { - name: "non-owner accessing public note", - user: anotherUser, - note: publicNote, - expectedOK: true, - expectedNote: publicNoteRecord, - }, - { - name: "guest accessing private note", - user: database.User{}, - note: privateNote, - expectedOK: false, - expectedNote: database.Note{}, - }, - { - name: "guest accessing public note", - user: database.User{}, - note: publicNote, - expectedOK: true, - expectedNote: publicNoteRecord, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - a := NewTest(nil) - note, ok, err := GetNote(a.DB, tc.note.UUID, tc.user) - if err != nil { - t.Fatal(errors.Wrap(err, "executing")) - } - - assert.Equal(t, ok, tc.expectedOK, "ok mismatch") - assert.DeepEqual(t, note, tc.expectedNote, "note mismatch") - }) - } -} - -func TestGetNote_nonexistent(t *testing.T) { user := testutils.SetupUserData() - defer testutils.ClearData() - - b1 := database.Book{ - UserID: user.ID, - Label: "js", - } + b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") - - n1UUID := "4fd19336-671e-4ff3-8f22-662b80e22edc" - n1 := database.Note{ - UUID: n1UUID, - UserID: user.ID, - BookUUID: b1.UUID, - Body: "n1 content", - Deleted: false, - Public: false, - } + n1 := database.Note{UserID: user.ID, Deleted: false, Body: "n1", BookUUID: b1.UUID} testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1") + n2 := database.Note{UserID: user.ID, Deleted: false, Body: "n2", BookUUID: b1.UUID} + testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2") + + d1 := database.Digest{UserID: user.ID} + testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1") + dn1 := database.DigestNote{NoteID: n1.ID, DigestID: d1.ID} + testutils.MustExec(t, testutils.DB.Save(&dn1), "preparing dn1") + dn2 := database.DigestNote{NoteID: n2.ID, DigestID: d1.ID} + testutils.MustExec(t, testutils.DB.Save(&dn2), "preparing dn2") a := NewTest(nil) - nonexistentUUID := "4fd19336-671e-4ff3-8f22-662b80e22edd" - note, ok, err := GetNote(a.DB, nonexistentUUID, user) - if err != nil { - t.Fatal(errors.Wrap(err, "executing")) - } - assert.Equal(t, ok, false, "ok mismatch") - assert.DeepEqual(t, note, database.Note{}, "note mismatch") + tx := testutils.DB.Begin() + if _, err := a.DeleteNote(tx, user, n1); err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "deleting note")) + } + tx.Commit() + + var noteCount, digestNoteCount int + var dn2Record database.DigestNote + + testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(¬eCount), "counting notes") + testutils.MustExec(t, testutils.DB.Model(&database.DigestNote{}).Count(&digestNoteCount), "counting digest_notes") + + assert.Equal(t, noteCount, 2, "note count mismatch") + assert.Equal(t, digestNoteCount, 1, "digest_notes count mismatch") + + testutils.MustExec(t, testutils.DB.Where("id = ?", dn2.ID).First(&dn2Record), "finding dn2") + assert.Equal(t, dn2Record.NoteID, dn2.NoteID, "dn2 NoteID mismatch") + assert.Equal(t, dn2Record.DigestID, dn2.DigestID, "dn2 DigestID mismatch") } diff --git a/pkg/server/database/database.go b/pkg/server/database/database.go index d2d07992..511632ad 100644 --- a/pkg/server/database/database.go +++ b/pkg/server/database/database.go @@ -46,7 +46,10 @@ func InitSchema(db *gorm.DB) { EmailPreference{}, Session{}, Digest{}, + DigestNote{}, RepetitionRule{}, + DigestReceipt{}, + NoteReview{}, ).Error; err != nil { panic(err) } diff --git a/pkg/server/database/migrations/20191225185502-populate-digest-version.sql b/pkg/server/database/migrations/20191225185502-populate-digest-version.sql new file mode 100644 index 00000000..7bc4c868 --- /dev/null +++ b/pkg/server/database/migrations/20191225185502-populate-digest-version.sql @@ -0,0 +1,19 @@ +-- populate-digest-version.sql populates the `version` column for the digests +-- by assigining an incremental integer scoped to a repetition rule that each +-- digest belongs, ordered by created_at timestamp of the digests. + +-- +migrate Up +UPDATE digests +SET version=t1.version +FROM ( + SELECT + digests.uuid, + ROW_NUMBER() OVER (PARTITION BY digests.rule_id ORDER BY digests.created_at) AS version + FROM digests + WHERE digests.rule_id IS NOT NULL +) AS t1 +WHERE digests.uuid = t1.uuid; + +-- +migrate Down +UPDATE digests +SET version=0; diff --git a/pkg/server/database/migrations/20191226093447-add-digest-id-primary-key.sql b/pkg/server/database/migrations/20191226093447-add-digest-id-primary-key.sql new file mode 100644 index 00000000..febdc4b5 --- /dev/null +++ b/pkg/server/database/migrations/20191226093447-add-digest-id-primary-key.sql @@ -0,0 +1,10 @@ + +-- +migrate Up + +ALTER TABLE digests DROP CONSTRAINT digests_pkey; +ALTER TABLE digests ADD PRIMARY KEY (id); + +-- +migrate Down + +ALTER TABLE digests DROP CONSTRAINT digests_pkey; +ALTER TABLE digests ADD PRIMARY KEY (uuid); diff --git a/pkg/server/database/migrations/20191226105659-use-id-in-digest-notes-joining-table.sql b/pkg/server/database/migrations/20191226105659-use-id-in-digest-notes-joining-table.sql new file mode 100644 index 00000000..24a45662 --- /dev/null +++ b/pkg/server/database/migrations/20191226105659-use-id-in-digest-notes-joining-table.sql @@ -0,0 +1,49 @@ +-- -use-id-in-digest-notes-joining-table.sql replaces uuids with ids +-- as foreign keys in the digest_notes joining table. + +-- +migrate Up + +DO $$ + +-- +migrate StatementBegin +BEGIN + PERFORM column_name FROM information_schema.columns WHERE table_name= 'digest_notes' and column_name = 'digest_id'; + IF NOT found THEN + ALTER TABLE digest_notes ADD COLUMN digest_id int; + END IF; + PERFORM column_name FROM information_schema.columns WHERE table_name= 'digest_notes' and column_name = 'note_id'; + IF NOT found THEN + ALTER TABLE digest_notes ADD COLUMN note_id int; + END IF; + + -- migrate if digest_notes.digest_uuid exists + PERFORM column_name FROM information_schema.columns WHERE table_name= 'digest_notes' and column_name = 'digest_uuid'; + IF found THEN + -- update note_id + UPDATE digest_notes + SET note_id=t1.note_id + FROM ( + SELECT notes.id AS note_id, notes.uuid AS note_uuid + FROM digest_notes + INNER JOIN notes ON notes.uuid = digest_notes.note_uuid + ) AS t1 + WHERE digest_notes.note_uuid = t1.note_uuid; + + -- update digest_id + UPDATE digest_notes + SET digest_id=t1.digest_id + FROM ( + SELECT digests.id AS digest_id, digests.uuid AS digest_uuid + FROM digest_notes + INNER JOIN digests ON digests.uuid = digest_notes.digest_uuid + ) AS t1 + WHERE digest_notes.digest_uuid = t1.digest_uuid; + + ALTER TABLE digest_notes DROP COLUMN digest_uuid; + ALTER TABLE digest_notes DROP COLUMN note_uuid; + END IF; +END; $$ +-- +migrate StatementEnd + + +-- +migrate Down diff --git a/pkg/server/database/migrations/20191226152111-delete-outdated-digests.sql b/pkg/server/database/migrations/20191226152111-delete-outdated-digests.sql new file mode 100644 index 00000000..9b093329 --- /dev/null +++ b/pkg/server/database/migrations/20191226152111-delete-outdated-digests.sql @@ -0,0 +1,15 @@ +-- delete-outdated-digests.sql deletes digests that do not belong to any repetition rules, +-- along with digest_notes associations. + +-- +migrate Up +DELETE +FROM digest_notes +USING digests +WHERE + digests.rule_id IS NULL AND + digests.id = digest_notes.digest_id; + +DELETE FROM digests +WHERE digests.rule_id IS NULL; + +-- +migrate Down diff --git a/pkg/server/database/models.go b/pkg/server/database/models.go index 97a83895..c206244c 100644 --- a/pkg/server/database/models.go +++ b/pkg/server/database/models.go @@ -46,19 +46,20 @@ type Book struct { // Note is a model for a note type Note struct { Model - UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"` - Book Book `json:"book" gorm:"foreignkey:BookUUID"` - User User `json:"user"` - UserID int `json:"user_id" gorm:"index"` - BookUUID string `json:"book_uuid" gorm:"index;type:uuid"` - Body string `json:"content"` - AddedOn int64 `json:"added_on"` - EditedOn int64 `json:"edited_on"` - TSV string `json:"-" gorm:"type:tsvector"` - Public bool `json:"public" gorm:"default:false"` - USN int `json:"-" gorm:"index"` - Deleted bool `json:"-" gorm:"default:false"` - Encrypted bool `json:"-" gorm:"default:false"` + UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"` + Book Book `json:"book" gorm:"foreignkey:BookUUID"` + User User `json:"user"` + UserID int `json:"user_id" gorm:"index"` + BookUUID string `json:"book_uuid" gorm:"index;type:uuid"` + Body string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` + TSV string `json:"-" gorm:"type:tsvector"` + Public bool `json:"public" gorm:"default:false"` + USN int `json:"-" gorm:"index"` + Deleted bool `json:"-" gorm:"default:false"` + Encrypted bool `json:"-" gorm:"default:false"` + NoteReview NoteReview `json:"-"` } // User is a model for a user @@ -128,12 +129,22 @@ type Session struct { // Digest is a digest of notes type Digest struct { - UUID string `json:"uuid" gorm:"primary_key:true;type:uuid;index;default:uuid_generate_v4()"` - RuleID int `gorm:"index"` - UserID int `gorm:"index"` - Notes []Note `gorm:"many2many:digest_notes;association_foreignKey:uuid;association_jointable_foreignkey:note_uuid;jointable_foreignkey:digest_uuid;"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Model + UUID string `json:"uuid" gorm:"type:uuid;index;default:uuid_generate_v4()"` + RuleID int `gorm:"index"` + Rule RepetitionRule `json:"rule"` + UserID int `gorm:"index"` + Version int `gorm:"version"` + Notes []Note `gorm:"many2many:digest_notes;"` + Receipts []DigestReceipt `gorm:"polymorphic:Target;"` +} + +// DigestNote is an intermediary to represent many-to-many relationship +// between digests and notes +type DigestNote struct { + Model + NoteID int `gorm:"index"` + DigestID int `gorm:"index"` } // RepetitionRule is the rules for sending digest emails @@ -155,3 +166,19 @@ type RepetitionRule struct { Books []Book `gorm:"many2many:repetition_rule_books;"` NoteCount int `json:"note_count"` } + +// DigestReceipt is a read receipt for digests +type DigestReceipt struct { + Model + UserID int `json:"user_id" gorm:"index"` + DigestID int `json:"digest_id" gorm:"index"` +} + +// NoteReview is a record for reviewing a note in a digest +type NoteReview struct { + Model + UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"` + UserID int `json:"user_id" gorm:"index"` + DigestID int `json:"digest_id" gorm:"index"` + NoteID int `json:"note_id" gorm:"index"` +} diff --git a/pkg/server/handlers/digests.go b/pkg/server/handlers/digests.go new file mode 100644 index 00000000..212203e4 --- /dev/null +++ b/pkg/server/handlers/digests.go @@ -0,0 +1,157 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/dnote/dnote/pkg/server/app" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/log" + "github.com/dnote/dnote/pkg/server/presenters" + "github.com/gorilla/mux" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +func preloadDigest(conn *gorm.DB, userID int) *gorm.DB { + return conn. + Preload("Notes", func(db *gorm.DB) *gorm.DB { + return db.Order("notes.created_at DESC") + }). + Preload("Notes.Book"). + Preload("Notes.NoteReview"). + Preload("Rule"). + Preload("Receipts", func(db *gorm.DB) *gorm.DB { + return db.Where("digest_receipts.user_id = ?", userID) + }) +} + +func (a *API) getDigest(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(helpers.KeyUser).(database.User) + if !ok { + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + digestUUID := vars["digestUUID"] + + db := a.App.DB + + var digest database.Digest + conn := db.Where("user_id = ? AND uuid = ? ", user.ID, digestUUID) + conn = preloadDigest(conn, user.ID) + conn = conn.First(&digest) + + if conn.RecordNotFound() { + RespondNotFound(w) + return + } else if err := conn.Error; err != nil { + HandleError(w, "finding digest", err, http.StatusInternalServerError) + return + } + + // mark as read + if _, err := a.App.MarkDigestRead(digest, user); err != nil { + log.ErrorWrap(err, fmt.Sprintf("marking digest as read for %s", digest.UUID)) + } + + presented := presenters.PresentDigest(digest) + respondJSON(w, http.StatusOK, presented) +} + +// DigestsResponse is a response for getting digests +type DigestsResponse struct { + Total int `json:"total"` + Items []presenters.Digest `json:"items"` +} + +type getDigestsParams struct { + page int + status string +} + +func parseGetDigestsParams(r *http.Request) (getDigestsParams, error) { + var page int + var err error + + q := r.URL.Query() + + pageStr := q.Get("page") + if pageStr != "" { + page, err = strconv.Atoi(pageStr) + if err != nil { + return getDigestsParams{}, errors.Wrap(err, "parsing page") + } + } else { + page = 1 + } + + status := q.Get("status") + + return getDigestsParams{ + page: page, + status: status, + }, nil +} + +func (a *API) getDigests(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(helpers.KeyUser).(database.User) + if !ok { + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + return + } + + params, err := parseGetDigestsParams(r) + if err != nil { + HandleError(w, "parsing params", err, http.StatusBadRequest) + return + } + + perPage := 30 + offset := (params.page - 1) * perPage + p := app.GetDigestsParam{ + UserID: user.ID, + Offset: offset, + PerPage: perPage, + Status: params.status, + Order: "created_at DESC", + } + + digests, err := a.App.GetDigests(p) + if err != nil { + HandleError(w, "querying digests", err, http.StatusInternalServerError) + return + } + + total, err := a.App.CountDigests(p) + if err != nil { + HandleError(w, "counting digests", err, http.StatusInternalServerError) + return + } + + respondJSON(w, http.StatusOK, DigestsResponse{ + Total: total, + Items: presenters.PresentDigests(digests), + }) +} diff --git a/pkg/server/handlers/digests_test.go b/pkg/server/handlers/digests_test.go new file mode 100644 index 00000000..762a5c8a --- /dev/null +++ b/pkg/server/handlers/digests_test.go @@ -0,0 +1,132 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package handlers + +import ( + "fmt" + "net/http" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestGetDigest_Permission(t *testing.T) { + defer testutils.ClearData() + + // Setup + server := MustNewServer(t, nil) + defer server.Close() + + owner := testutils.SetupUserData() + nonOwner := testutils.SetupUserData() + digest := database.Digest{ + UserID: owner.ID, + } + testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest") + + t.Run("owner", func(t *testing.T) { + // Execute + req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "") + res := testutils.HTTPAuthDo(t, req, owner) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + }) + + t.Run("non owner", func(t *testing.T) { + // Execute + req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "") + res := testutils.HTTPAuthDo(t, req, nonOwner) + + // Test + assert.StatusCodeEquals(t, res, http.StatusNotFound, "") + }) + + t.Run("guest", func(t *testing.T) { + // Execute + req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "") + res := testutils.HTTPDo(t, req) + + // Test + assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "") + }) +} + +func TestGetDigest_Receipt(t *testing.T) { + defer testutils.ClearData() + + // Setup + server := MustNewServer(t, nil) + defer server.Close() + + user := testutils.SetupUserData() + digest := database.Digest{ + UserID: user.ID, + } + testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest") + + // multiple requests should create at most one receipt + for i := 0; i < 3; i++ { + // Execute and test + req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "") + res := testutils.HTTPAuthDo(t, req, user) + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var receiptCount int + testutils.MustExec(t, testutils.DB.Model(&database.DigestReceipt{}).Count(&receiptCount), "counting receipts") + assert.Equal(t, receiptCount, 1, "counting receipt") + + var receipt database.DigestReceipt + testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&receipt), "finding receipt") + } +} + +func TestGetDigests(t *testing.T) { + defer testutils.ClearData() + + // Setup + server := MustNewServer(t, nil) + defer server.Close() + + user := testutils.SetupUserData() + digest := database.Digest{ + UserID: user.ID, + } + testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest") + + t.Run("user", func(t *testing.T) { + // Execute + req := testutils.MakeReq(server.URL, "GET", "/digests", "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + }) + + t.Run("guest", func(t *testing.T) { + // Execute + req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "") + res := testutils.HTTPDo(t, req) + + // Test + assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "") + }) +} diff --git a/pkg/server/handlers/note_review.go b/pkg/server/handlers/note_review.go new file mode 100644 index 00000000..a7322572 --- /dev/null +++ b/pkg/server/handlers/note_review.go @@ -0,0 +1,150 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +type createNoteReviewParams struct { + DigestUUID string `json:"digest_uuid"` + NoteUUID string `json:"note_uuid"` +} + +func getDigestByUUID(db *gorm.DB, uuid string) (*database.Digest, error) { + var ret database.Digest + conn := db.Where("uuid = ?", uuid).First(&ret) + + if conn.RecordNotFound() { + return nil, nil + } + if err := conn.Error; err != nil { + return nil, errors.Wrap(err, "finding digest") + } + + return &ret, nil +} + +func (a *API) createNoteReview(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(helpers.KeyUser).(database.User) + if !ok { + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + return + } + + var params createNoteReviewParams + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + HandleError(w, "decoding params", err, http.StatusInternalServerError) + return + } + + digest, err := a.App.GetUserDigestByUUID(user.ID, params.DigestUUID) + if digest == nil { + http.Error(w, "digest not found for the given uuid", http.StatusBadRequest) + return + } + if err != nil { + HandleError(w, "finding digest", err, http.StatusInternalServerError) + return + } + + note, err := a.App.GetUserNoteByUUID(user.ID, params.NoteUUID) + if note == nil { + http.Error(w, "note not found for the given uuid", http.StatusBadRequest) + return + } + if err != nil { + HandleError(w, "finding note", err, http.StatusInternalServerError) + return + } + + var nr database.NoteReview + if err := a.App.DB.FirstOrCreate(&nr, database.NoteReview{ + UserID: user.ID, + DigestID: digest.ID, + NoteID: note.ID, + }).Error; err != nil { + HandleError(w, "saving note review", err, http.StatusInternalServerError) + return + } +} + +type deleteNoteReviewParams struct { + DigestUUID string `json:"digest_uuid"` + NoteUUID string `json:"note_uuid"` +} + +func (a *API) deleteNoteReview(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(helpers.KeyUser).(database.User) + if !ok { + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + return + } + + var params deleteNoteReviewParams + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + HandleError(w, "decoding params", err, http.StatusInternalServerError) + return + } + + db := a.App.DB + + note, err := a.App.GetUserNoteByUUID(user.ID, params.NoteUUID) + if note == nil { + http.Error(w, "note not found for the given uuid", http.StatusBadRequest) + return + } + if err != nil { + HandleError(w, "finding note", err, http.StatusInternalServerError) + return + } + + digest, err := a.App.GetUserDigestByUUID(user.ID, params.DigestUUID) + if digest == nil { + http.Error(w, "digest not found for the given uuid", http.StatusBadRequest) + return + } + if err != nil { + HandleError(w, "finding digest", err, http.StatusInternalServerError) + return + } + + var nr database.NoteReview + conn := db.Where("note_id = ? AND digest_id = ? AND user_id = ?", note.ID, digest.ID, user.ID).First(&nr) + if conn.RecordNotFound() { + http.Error(w, "no record found", http.StatusBadRequest) + return + } else if err := conn.Error; err != nil { + HandleError(w, "finding record", err, http.StatusInternalServerError) + return + } + + if err := db.Delete(&nr).Error; err != nil { + HandleError(w, "deleting record", err, http.StatusInternalServerError) + return + } +} diff --git a/pkg/server/handlers/note_review_test.go b/pkg/server/handlers/note_review_test.go new file mode 100644 index 00000000..7fc810cb --- /dev/null +++ b/pkg/server/handlers/note_review_test.go @@ -0,0 +1,113 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package handlers + +import ( + "fmt" + "net/http" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestCreateNoteReview(t *testing.T) { + defer testutils.ClearData() + + // Setup + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), + }) + defer server.Close() + + user := testutils.SetupUserData() + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") + n1 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + } + testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1") + d1 := database.Digest{ + UserID: user.ID, + } + testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1") + + // multiple requests should create at most one receipt + for i := 0; i < 3; i++ { + dat := fmt.Sprintf(`{"note_uuid": "%s", "digest_uuid": "%s"}`, n1.UUID, d1.UUID) + req := testutils.MakeReq(server.URL, http.MethodPost, "/note_review", dat) + res := testutils.HTTPAuthDo(t, req, user) + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var noteReviewCount int + testutils.MustExec(t, testutils.DB.Model(&database.NoteReview{}).Count(¬eReviewCount), "counting note_reviews") + assert.Equalf(t, noteReviewCount, 1, "counting note_review") + + var noteReviewRecord database.NoteReview + testutils.MustExec(t, testutils.DB.Where("user_id = ? AND note_id = ? AND digest_id = ?", user.ID, n1.ID, d1.ID).First(¬eReviewRecord), "finding note_review record") + } +} + +func TestDeleteNoteReview(t *testing.T) { + defer testutils.ClearData() + + // Setup + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), + }) + defer server.Close() + + user := testutils.SetupUserData() + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") + n1 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + } + testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1") + d1 := database.Digest{ + UserID: user.ID, + } + testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1") + nr1 := database.NoteReview{ + UserID: user.ID, + NoteID: n1.ID, + DigestID: d1.ID, + } + testutils.MustExec(t, testutils.DB.Save(&nr1), "preparing nr1") + + dat := fmt.Sprintf(`{"note_uuid": "%s", "digest_uuid": "%s"}`, n1.UUID, d1.UUID) + req := testutils.MakeReq(server.URL, http.MethodDelete, "/note_review", dat) + res := testutils.HTTPAuthDo(t, req, user) + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var noteReviewCount int + testutils.MustExec(t, testutils.DB.Model(&database.NoteReview{}).Count(¬eReviewCount), "counting note_reviews") + assert.Equal(t, noteReviewCount, 0, "counting note_review") +} diff --git a/pkg/server/handlers/notes.go b/pkg/server/handlers/notes.go index ec4f30ad..d63e72e9 100644 --- a/pkg/server/handlers/notes.go +++ b/pkg/server/handlers/notes.go @@ -26,9 +26,9 @@ import ( "strings" "time" - "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" "github.com/jinzhu/gorm" @@ -109,7 +109,7 @@ func (a *API) getNote(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) noteUUID := vars["noteUUID"] - note, ok, err := app.GetNote(a.App.DB, noteUUID, user) + note, ok, err := operations.GetNote(a.App.DB, noteUUID, user) if !ok { RespondNotFound(w) return diff --git a/pkg/server/handlers/notes_test.go b/pkg/server/handlers/notes_test.go index e6121cc7..1146f44b 100644 --- a/pkg/server/handlers/notes_test.go +++ b/pkg/server/handlers/notes_test.go @@ -56,12 +56,10 @@ func getExpectedNotePayload(n database.Note, b database.Book, u database.User) p } func TestGetNotes(t *testing.T) { - defer testutils.ClearData() // Setup server := MustNewServer(t, &app.App{ - Clock: clock.NewMock(), }) defer server.Close() @@ -168,12 +166,10 @@ func TestGetNotes(t *testing.T) { } func TestGetNote(t *testing.T) { - defer testutils.ClearData() // Setup server := MustNewServer(t, &app.App{ - Clock: clock.NewMock(), }) defer server.Close() diff --git a/pkg/server/handlers/routes.go b/pkg/server/handlers/routes.go index 1417677c..ecd5d641 100644 --- a/pkg/server/handlers/routes.go +++ b/pkg/server/handlers/routes.go @@ -356,6 +356,10 @@ func (a *API) NewRouter() (*mux.Router, error) { {"POST", "/repetition_rules", a.auth(a.createRepetitionRule, &proOnly), true}, {"PATCH", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, {"DELETE", "/repetition_rules/{repetitionRuleUUID}", a.auth(a.deleteRepetitionRule, &proOnly), true}, + {"GET", "/digests/{digestUUID}", a.auth(a.getDigest, nil), true}, + {"GET", "/digests", a.auth(a.getDigests, nil), true}, + {"POST", "/note_review", a.auth(a.createNoteReview, nil), true}, + {"DELETE", "/note_review", a.auth(a.deleteNoteReview, nil), true}, // migration of classic users {"GET", "/classic/presignin", cors(a.classicPresignin), true}, diff --git a/pkg/server/job/repetition/repetition.go b/pkg/server/job/repetition/repetition.go index 51a2e3e5..1d1c3ef9 100644 --- a/pkg/server/job/repetition/repetition.go +++ b/pkg/server/job/repetition/repetition.go @@ -28,6 +28,7 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/operations" "github.com/jinzhu/gorm" "github.com/pkg/errors" ) @@ -51,8 +52,7 @@ type BuildEmailParams struct { // BuildEmail builds an email for the spaced repetition func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (string, string, error) { - date := p.Now.Format("Jan 02 2006") - subject := fmt.Sprintf("%s %s", p.Rule.Title, date) + subject := fmt.Sprintf("%s #%d", p.Rule.Title, p.Digest.Version) tok, err := mailer.GetToken(db, p.User, database.TokenTypeRepetition) if err != nil { return "", "", errors.Wrap(err, "getting email frequency token") @@ -86,16 +86,14 @@ func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (st } tmplData := mailer.DigestTmplData{ - Subject: subject, - NoteInfo: noteInfos, - ActiveBookCount: bookCount, - ActiveNoteCount: len(p.Digest.Notes), EmailSessionToken: tok.Value, + DigestUUID: p.Digest.UUID, + DigestVersion: p.Digest.Version, RuleUUID: p.Rule.UUID, RuleTitle: p.Rule.Title, WebURL: os.Getenv("WebURL"), } - body, err := emailTmpl.Execute(mailer.EmailTypeDigest, mailer.EmailKindHTML, tmplData) + body, err := emailTmpl.Execute(mailer.EmailTypeDigest, mailer.EmailKindText, tmplData) if err != nil { return "", "", errors.Wrap(err, "executing digest email template") } @@ -124,13 +122,9 @@ func build(tx *gorm.DB, rule database.RepetitionRule) (database.Digest, error) { return database.Digest{}, errors.Wrap(err, "getting notes") } - digest := database.Digest{ - RuleID: rule.ID, - UserID: rule.UserID, - Notes: notes, - } - if err := tx.Save(&digest).Error; err != nil { - return database.Digest{}, errors.Wrap(err, "saving digest") + digest, err := operations.CreateDigest(tx, rule, notes) + if err != nil { + return database.Digest{}, errors.Wrap(err, "creating digest") } return digest, nil diff --git a/pkg/server/job/repetition/strategy.go b/pkg/server/job/repetition/strategy.go index 2f03dc41..78d74624 100644 --- a/pkg/server/job/repetition/strategy.go +++ b/pkg/server/job/repetition/strategy.go @@ -77,15 +77,17 @@ func getBalancedNotes(db *gorm.DB, rule database.RepetitionRule) ([]database.Not t1 := now.AddDate(0, 0, -3).UnixNano() t2 := now.AddDate(0, 0, -7).UnixNano() + baseConn := db.Where("notes.deleted IS NOT true") + // Get notes into three buckets with different threshold values var stage1, stage2, stage3 []database.Note - if err := getNotes(db, db.Where("notes.added_on > ?", t1), rule, &stage1); err != nil { + if err := getNotes(db, baseConn.Where("notes.added_on > ?", t1), rule, &stage1); err != nil { return nil, errors.Wrap(err, "Failed to get notes with threshold 1") } - if err := getNotes(db, db.Where("notes.added_on > ? AND notes.added_on < ?", t2, t1), rule, &stage2); err != nil { + if err := getNotes(db, baseConn.Where("notes.added_on > ? AND notes.added_on < ?", t2, t1), rule, &stage2); err != nil { return nil, errors.Wrap(err, "Failed to get notes with threshold 2") } - if err := getNotes(db, db.Where("notes.added_on < ?", t2), rule, &stage3); err != nil { + if err := getNotes(db, baseConn.Where("notes.added_on < ?", t2), rule, &stage3); err != nil { return nil, errors.Wrap(err, "Failed to get notes with threshold 3") } diff --git a/pkg/server/mailer/mailer.go b/pkg/server/mailer/mailer.go index 5a2689f8..93dea7bc 100644 --- a/pkg/server/mailer/mailer.go +++ b/pkg/server/mailer/mailer.go @@ -93,10 +93,6 @@ func NewTemplates(srcDir *string) Templates { box = packr.New("emailTemplates", "./templates/src") } - weeklyDigestHTML, err := initHTMLTmpl(box, EmailTypeDigest) - if err != nil { - panic(errors.Wrap(err, "initializing weekly digest template")) - } welcomeText, err := initTextTmpl(box, EmailTypeWelcome) if err != nil { panic(errors.Wrap(err, "initializing welcome template")) @@ -121,15 +117,19 @@ func NewTemplates(srcDir *string) Templates { if err != nil { panic(errors.Wrap(err, "initializing password reset template")) } + digestText, err := initTextTmpl(box, EmailTypeDigest) + if err != nil { + panic(errors.Wrap(err, "initializing digest template")) + } T := Templates{} - T.set(EmailTypeDigest, EmailKindHTML, weeklyDigestHTML) T.set(EmailTypeResetPassword, EmailKindText, passwordResetText) T.set(EmailTypeResetPasswordAlert, EmailKindText, passwordResetAlertText) T.set(EmailTypeEmailVerification, EmailKindText, verifyEmailText) T.set(EmailTypeWelcome, EmailKindText, welcomeText) T.set(EmailTypeInactiveReminder, EmailKindText, inactiveReminderText) T.set(EmailTypeSubscriptionConfirmation, EmailKindText, subscriptionConfirmationText) + T.set(EmailTypeDigest, EmailKindText, digestText) return T } diff --git a/pkg/server/mailer/templates/.env.dev b/pkg/server/mailer/templates/.env.dev index a762d8b8..7808cb4a 100644 --- a/pkg/server/mailer/templates/.env.dev +++ b/pkg/server/mailer/templates/.env.dev @@ -1,5 +1,5 @@ DBHost=localhost -DBPort=5432 +DBPort=5433 DBName=dnote DBUser=postgres DBPassword= @@ -7,3 +7,6 @@ DBPassword= SmtpUsername=mock-SmtpUsername SmtpPassword=mock-SmtpPassword SmtpHost=mock-SmtpHost + +WebURL=http://localhost:3000 +DisableRegistration=false diff --git a/pkg/server/mailer/templates/src/digest.html b/pkg/server/mailer/templates/src/digest.html deleted file mode 100644 index f141f5de..00000000 --- a/pkg/server/mailer/templates/src/digest.html +++ /dev/null @@ -1,451 +0,0 @@ - - - - - - {{ .Subject }} - - - - - - - - -
-
- Here is your automated spaced repetition. - - {{ template "header" }} - - - - - - - - - -
- - - - - - - - - - - - -
- This is your Dnote spaced repetition. -
- - {{ .RuleTitle }} - -
- - - {{ range .NoteInfo }} - - - - - - - {{ end }} - -
- - - - - - - - - - - - -
- {{ .Content }} -
- Open -
- book label{{ .BookLabel }} {{ .TimeAgo }} -
-
- -
-
- - - - - - -
-
 
- - diff --git a/pkg/server/mailer/templates/src/digest.txt b/pkg/server/mailer/templates/src/digest.txt new file mode 100644 index 00000000..7b580a65 --- /dev/null +++ b/pkg/server/mailer/templates/src/digest.txt @@ -0,0 +1,15 @@ +REFRESH YOUR MEMORY + +There is a new automated spaced repetition "{{ .RuleTitle }} #{{ .DigestVersion }}" + + {{ .WebURL }}/digests/{{ .DigestUUID }} + + +MANAGE THE RULE + +Go to the following link to manage the notification and other settings for "{{ .RuleTitle }}" + + {{ .WebURL }}/preferences/repetitions/{{ .RuleUUID }}?token={{ .EmailSessionToken }} + +- Dnote team + diff --git a/pkg/server/mailer/templates/src/footer.html b/pkg/server/mailer/templates/src/footer.html deleted file mode 100644 index f75185a3..00000000 --- a/pkg/server/mailer/templates/src/footer.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ define "footer" }} - -{{ end }} diff --git a/pkg/server/mailer/templates/src/header.html b/pkg/server/mailer/templates/src/header.html deleted file mode 100644 index 39ab5c0a..00000000 --- a/pkg/server/mailer/templates/src/header.html +++ /dev/null @@ -1,31 +0,0 @@ -{{ define "header" }} - - - - - -
- - Dnote Logo - -
-{{ end }} diff --git a/pkg/server/mailer/types.go b/pkg/server/mailer/types.go index 35a18fe4..a276ddc5 100644 --- a/pkg/server/mailer/types.go +++ b/pkg/server/mailer/types.go @@ -49,11 +49,9 @@ func NewNoteInfo(note database.Note, stage int) DigestNoteInfo { // DigestTmplData is a template data for digest emails type DigestTmplData struct { - Subject string - NoteInfo []DigestNoteInfo - ActiveBookCount int - ActiveNoteCount int EmailSessionToken string + DigestUUID string + DigestVersion int RuleUUID string RuleTitle string WebURL string diff --git a/pkg/server/operations/digests.go b/pkg/server/operations/digests.go new file mode 100644 index 00000000..364ebe5f --- /dev/null +++ b/pkg/server/operations/digests.go @@ -0,0 +1,45 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package operations + +import ( + "github.com/dnote/dnote/pkg/server/database" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +// CreateDigest creates a new digest +func CreateDigest(db *gorm.DB, rule database.RepetitionRule, notes []database.Note) (database.Digest, error) { + var maxVersion int + if err := db.Raw("SELECT COALESCE(max(version), 0) FROM digests WHERE rule_id = ?", rule.ID).Row().Scan(&maxVersion); err != nil { + return database.Digest{}, errors.Wrap(err, "finding max version") + } + + digest := database.Digest{ + RuleID: rule.ID, + UserID: rule.UserID, + Version: maxVersion + 1, + Notes: notes, + } + if err := db.Save(&digest).Error; err != nil { + return database.Digest{}, errors.Wrap(err, "saving digest") + } + + return digest, nil +} diff --git a/pkg/server/operations/digests_test.go b/pkg/server/operations/digests_test.go new file mode 100644 index 00000000..eafe0f6e --- /dev/null +++ b/pkg/server/operations/digests_test.go @@ -0,0 +1,68 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package operations + +import ( + // "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func TestCreateDigest(t *testing.T) { + t.Run("no previous digest", func(t *testing.T) { + defer testutils.ClearData() + + db := testutils.DB + + user := testutils.SetupUserData() + rule := database.RepetitionRule{UserID: user.ID} + testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule") + + result, err := CreateDigest(db, rule, nil) + if err != nil { + t.Fatal(errors.Wrap(err, "performing")) + } + + assert.Equal(t, result.Version, 1, "Version mismatch") + }) + + t.Run("with previous digest", func(t *testing.T) { + defer testutils.ClearData() + + db := testutils.DB + + user := testutils.SetupUserData() + rule := database.RepetitionRule{UserID: user.ID} + testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule") + + d := database.Digest{UserID: user.ID, RuleID: rule.ID, Version: 8} + testutils.MustExec(t, testutils.DB.Save(&d), "preparing digest") + + result, err := CreateDigest(db, rule, nil) + if err != nil { + t.Fatal(errors.Wrap(err, "performing")) + } + + assert.Equal(t, result.Version, 9, "Version mismatch") + }) +} diff --git a/pkg/server/operations/doc.go b/pkg/server/operations/doc.go new file mode 100644 index 00000000..a3130e44 --- /dev/null +++ b/pkg/server/operations/doc.go @@ -0,0 +1,23 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +/* +Package operations encapsulates operations which involves +database and are not necessarily in the context of app. +*/ +package operations diff --git a/pkg/server/operations/main_test.go b/pkg/server/operations/main_test.go new file mode 100644 index 00000000..0bd13535 --- /dev/null +++ b/pkg/server/operations/main_test.go @@ -0,0 +1,35 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package operations + +import ( + "os" + "testing" + + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMain(m *testing.M) { + testutils.InitTestDB() + + code := m.Run() + testutils.ClearData() + + os.Exit(code) +} diff --git a/pkg/server/operations/notes.go b/pkg/server/operations/notes.go new file mode 100644 index 00000000..9bdc1b76 --- /dev/null +++ b/pkg/server/operations/notes.go @@ -0,0 +1,53 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package operations + +import ( + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/permissions" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +// GetNote retrieves a note for the given user +func GetNote(db *gorm.DB, uuid string, user database.User) (database.Note, bool, error) { + zeroNote := database.Note{} + if !helpers.ValidateUUID(uuid) { + return zeroNote, false, nil + } + + conn := db.Where("notes.uuid = ? AND deleted = ?", uuid, false) + conn = database.PreloadNote(conn) + + var note database.Note + conn = conn.Find(¬e) + + if conn.RecordNotFound() { + return zeroNote, false, nil + } else if err := conn.Error; err != nil { + return zeroNote, false, errors.Wrap(err, "finding note") + } + + if ok := permissions.ViewNote(&user, note); !ok { + return zeroNote, false, nil + } + + return note, true, nil +} diff --git a/pkg/server/operations/notes_test.go b/pkg/server/operations/notes_test.go new file mode 100644 index 00000000..5044025d --- /dev/null +++ b/pkg/server/operations/notes_test.go @@ -0,0 +1,151 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package operations + +import ( + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func TestGetNote(t *testing.T) { + user := testutils.SetupUserData() + anotherUser := testutils.SetupUserData() + + defer testutils.ClearData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") + + privateNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Deleted: false, + Public: false, + } + testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote") + + publicNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Deleted: false, + Public: true, + } + testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing privateNote") + + var privateNoteRecord, publicNoteRecord database.Note + testutils.MustExec(t, testutils.DB.Where("uuid = ?", privateNote.UUID).Preload("Book").Preload("User").First(&privateNoteRecord), "finding privateNote") + testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).Preload("Book").Preload("User").First(&publicNoteRecord), "finding publicNote") + + testCases := []struct { + name string + user database.User + note database.Note + expectedOK bool + expectedNote database.Note + }{ + { + name: "owner accessing private note", + user: user, + note: privateNote, + expectedOK: true, + expectedNote: privateNoteRecord, + }, + { + name: "non-owner accessing private note", + user: anotherUser, + note: privateNote, + expectedOK: false, + expectedNote: database.Note{}, + }, + { + name: "non-owner accessing public note", + user: anotherUser, + note: publicNote, + expectedOK: true, + expectedNote: publicNoteRecord, + }, + { + name: "guest accessing private note", + user: database.User{}, + note: privateNote, + expectedOK: false, + expectedNote: database.Note{}, + }, + { + name: "guest accessing public note", + user: database.User{}, + note: publicNote, + expectedOK: true, + expectedNote: publicNoteRecord, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + note, ok, err := GetNote(testutils.DB, tc.note.UUID, tc.user) + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.Equal(t, ok, tc.expectedOK, "ok mismatch") + assert.DeepEqual(t, note, tc.expectedNote, "note mismatch") + }) + } +} + +func TestGetNote_nonexistent(t *testing.T) { + user := testutils.SetupUserData() + + defer testutils.ClearData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") + + n1UUID := "4fd19336-671e-4ff3-8f22-662b80e22edc" + n1 := database.Note{ + UUID: n1UUID, + UserID: user.ID, + BookUUID: b1.UUID, + Body: "n1 content", + Deleted: false, + Public: false, + } + testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1") + + nonexistentUUID := "4fd19336-671e-4ff3-8f22-662b80e22edd" + note, ok, err := GetNote(testutils.DB, nonexistentUUID, user) + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.Equal(t, ok, false, "ok mismatch") + assert.DeepEqual(t, note, database.Note{}, "note mismatch") +} diff --git a/pkg/server/presenters/digest.go b/pkg/server/presenters/digest.go index a9505389..43105a6d 100644 --- a/pkg/server/presenters/digest.go +++ b/pkg/server/presenters/digest.go @@ -26,24 +26,36 @@ import ( // Digest is a presented digest type Digest struct { - UUID string `json:"uuid"` - Notes []Note `json:"notes"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UUID string `json:"uuid"` + Version int `json:"version"` + RepetitionRule RepetitionRule `json:"repetition_rule"` + Notes []DigestNote `json:"notes"` + IsRead bool `json:"is_read"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// PresentDigests presetns digests -func PresentDigests(digests []database.Digest) []Digest { - ret := []Digest{} +// DigestNote is a presented note inside a digest +type DigestNote struct { + Note + IsReviewed bool `json:"is_reviewed"` +} - for _, digest := range digests { - p := Digest{ - UUID: digest.UUID, - CreatedAt: digest.CreatedAt, - UpdatedAt: digest.UpdatedAt, - } +func presentDigestNote(note database.Note) DigestNote { + ret := DigestNote{ + Note: PresentNote(note), + IsReviewed: note.NoteReview.UUID != "", + } - ret = append(ret, p) + return ret +} + +func presentDigestNotes(notes []database.Note) []DigestNote { + ret := []DigestNote{} + + for _, note := range notes { + n := presentDigestNote(note) + ret = append(ret, n) } return ret @@ -52,8 +64,25 @@ func PresentDigests(digests []database.Digest) []Digest { // PresentDigest presents a digest func PresentDigest(digest database.Digest) Digest { ret := Digest{ - UUID: digest.UUID, - Notes: PresentNotes(digest.Notes), + UUID: digest.UUID, + Notes: presentDigestNotes(digest.Notes), + Version: digest.Version, + RepetitionRule: PresentRepetitionRule(digest.Rule), + IsRead: len(digest.Receipts) > 0, + CreatedAt: digest.CreatedAt, + UpdatedAt: digest.UpdatedAt, + } + + return ret +} + +// PresentDigests presetns digests +func PresentDigests(digests []database.Digest) []Digest { + ret := []Digest{} + + for _, digest := range digests { + p := PresentDigest(digest) + ret = append(ret, p) } return ret diff --git a/pkg/server/presenters/digest_receipt.go b/pkg/server/presenters/digest_receipt.go new file mode 100644 index 00000000..899c4014 --- /dev/null +++ b/pkg/server/presenters/digest_receipt.go @@ -0,0 +1,53 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package presenters + +import ( + "time" + + "github.com/dnote/dnote/pkg/server/database" +) + +// DigestReceipt is a presented receipt +type DigestReceipt struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PresentDigestReceipt presents a receipt +func PresentDigestReceipt(receipt database.DigestReceipt) DigestReceipt { + ret := DigestReceipt{ + CreatedAt: receipt.CreatedAt, + UpdatedAt: receipt.UpdatedAt, + } + + return ret +} + +// PresentDigestReceipts presents receipts +func PresentDigestReceipts(receipts []database.DigestReceipt) []DigestReceipt { + ret := []DigestReceipt{} + + for _, receipt := range receipts { + r := PresentDigestReceipt(receipt) + ret = append(ret, r) + } + + return ret +} diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index c2550783..b7d6beb5 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -80,10 +80,10 @@ func ClearData() { panic(errors.Wrap(err, "Failed to clear accounts")) } if err := DB.Delete(&database.Token{}).Error; err != nil { - panic(errors.Wrap(err, "Failed to clear reset_tokens")) + panic(errors.Wrap(err, "Failed to clear tokens")) } if err := DB.Delete(&database.EmailPreference{}).Error; err != nil { - panic(errors.Wrap(err, "Failed to clear reset_tokens")) + panic(errors.Wrap(err, "Failed to clear email preferences")) } if err := DB.Delete(&database.Session{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear sessions")) @@ -91,9 +91,18 @@ func ClearData() { if err := DB.Delete(&database.Digest{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear digests")) } - if err := DB.Delete(&database.RepetitionRule{}).Error; err != nil { + if err := DB.Delete(&database.DigestNote{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear digests")) } + if err := DB.Delete(&database.DigestReceipt{}).Error; err != nil { + panic(errors.Wrap(err, "Failed to clear digest receipts")) + } + if err := DB.Delete(&database.RepetitionRule{}).Error; err != nil { + panic(errors.Wrap(err, "Failed to clear repetition rules")) + } + if err := DB.Delete(&database.NoteReview{}).Error; err != nil { + panic(errors.Wrap(err, "Failed to clear note review")) + } } // SetupUserData creates and returns a new user for testing purposes diff --git a/pkg/server/tmpl/data.go b/pkg/server/tmpl/data.go index 5688c290..ec78bc0d 100644 --- a/pkg/server/tmpl/data.go +++ b/pkg/server/tmpl/data.go @@ -27,9 +27,9 @@ import ( "strings" "time" - "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/handlers" + "github.com/dnote/dnote/pkg/server/operations" "github.com/pkg/errors" ) @@ -57,7 +57,7 @@ func (a AppShell) newNotePage(r *http.Request, noteUUID string) (notePage, error return notePage{}, errors.Wrap(err, "authenticating with session") } - note, ok, err := app.GetNote(a.DB, noteUUID, user) + note, ok, err := operations.GetNote(a.DB, noteUUID, user) if !ok { return notePage{}, ErrNotFound diff --git a/web/assets/index.html b/web/assets/index.html index 99e92b2a..9bc4323f 100644 --- a/web/assets/index.html +++ b/web/assets/index.html @@ -43,9 +43,6 @@ diff --git a/web/src/components/App/App.scss b/web/src/components/App/App.scss index c21c07d0..5c308242 100644 --- a/web/src/components/App/App.scss +++ b/web/src/components/App/App.scss @@ -24,7 +24,7 @@ position: relative; display: flex; flex-direction: column; - background: $light-gray; + background: $lighter-gray; min-height: calc(100vh - #{$header-height} - #{$footer-height}); margin-bottom: $footer-height; diff --git a/web/src/components/App/_buttons.scss b/web/src/components/App/_buttons.scss index b16cd489..3ed33198 100644 --- a/web/src/components/App/_buttons.scss +++ b/web/src/components/App/_buttons.scss @@ -170,6 +170,10 @@ button:disabled { cursor: pointer; } +.button-no-padding { + padding: 0; +} + .button-link { color: $link; diff --git a/web/src/components/App/_shared.scss b/web/src/components/App/_shared.scss index e1a1663e..dbb523b2 100644 --- a/web/src/components/App/_shared.scss +++ b/web/src/components/App/_shared.scss @@ -35,6 +35,10 @@ .holder { animation: holderPulse 800ms infinite; background: #f4f4f4; + + &.holder-dark { + background: #e6e6e6; + } } input[type='text']:disabled, @@ -42,7 +46,7 @@ input[type='email']:disabled, input[type='number']:disabled, input[type='password']:disabled, textarea:disabled { - background-color: $light-gray; + background-color: $lighter-gray; cursor: not-allowed; } @@ -166,10 +170,14 @@ html body { .page-header { margin-top: rem(20px); - margin-bottom: rem(20px); + + &.page-header-full { + margin-bottom: rem(20px); + } @include breakpoint(lg) { - padding: 0; + // padding: 0; + margin-bottom: rem(20px); margin-top: 0; } } @@ -199,7 +207,7 @@ html body { &:disabled, &.form-select-disabled { background-image: url('data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEKSURBVHgBzVTNDYIwFC4NB46OwAi4gY7gETgoE6gTGCcwTgAJ4efGCLCBjMAIXrmA3yOhQazQhJj4JQ0v7fte3/e1hbFfIk3TYxzHp6kc7dtCFEUW5/xBcdM0a9d1S1kel00mSWKCnIkkxDSnXADIMYYEU9O0zPf91WwB6L6NyB3atrUMw7hNFkCbFyROmXYYmypMDMNwo+t6ztSwtW27oEAXrXBuwu2rCht+WPgU7C8gPCBzYOBKhQS5FTwIKBYeQFeJoWyiKNYH5Co6OCuQr/0JdBuPVyElQCd7GRMb3B3HebsHHzexrmvyQvZwqjFZWsDzvCc62BFhSGYD3UMsfs6ToKOd+6EsxgtrtWLW4gUN3AAAAABJRU5ErkJggg=='); - background-color: $light-gray; + background-color: $lighter-gray; } } @@ -214,3 +222,14 @@ html body { .page-heading { @include font-size('x-large'); } + +.dropdown-caret { + display: inline-block; + vertical-align: middle; + border-top-width: 4px; + border-top-style: solid; + border-right: 4px solid transparent; + border-bottom: 0 solid transparent; + border-left: 4px solid transparent; + margin-left: rem(8px); +} diff --git a/web/src/components/App/_theme.scss b/web/src/components/App/_theme.scss index 296e8b16..99e4d099 100644 --- a/web/src/components/App/_theme.scss +++ b/web/src/components/App/_theme.scss @@ -21,7 +21,8 @@ $black: #2a2a2a; $white: #ffffff; $light: #f7f9fa; $gray: #686868; -$light-gray: #f3f3f3; +$light-gray: #8c8c8c; +$lighter-gray: #f3f3f3; $dark-gray: #637283; // primary colors @@ -31,7 +32,7 @@ $third: #0a4b73; // functional colors $border-color: #d8d8d8; -$border-color-light: $light-gray; +$border-color-light: $lighter-gray; $link: #6f53c0; $link-hover: darken($link, 5%); @@ -39,5 +40,8 @@ $link-hover: darken($link, 5%); $danger-text: #cb2431; $danger-background: #f8d7da; +$blue: #0668d7; $light-blue: #ecf4ff; $green: #28a755; + +$active: #49abfd; diff --git a/web/src/components/Books/BookHolder.module.scss b/web/src/components/Books/BookHolder.scss similarity index 100% rename from web/src/components/Books/BookHolder.module.scss rename to web/src/components/Books/BookHolder.scss diff --git a/web/src/components/Books/BookHolder.js b/web/src/components/Books/BookHolder.tsx similarity index 95% rename from web/src/components/Books/BookHolder.js rename to web/src/components/Books/BookHolder.tsx index 0d29da75..d26569a4 100644 --- a/web/src/components/Books/BookHolder.js +++ b/web/src/components/Books/BookHolder.tsx @@ -19,7 +19,7 @@ import React from 'react'; import classnames from 'classnames'; -import styles from './BookHolder.module.scss'; +import styles from './BookHolder.scss'; export default () => { return ( diff --git a/web/src/components/Books/BookList.scss b/web/src/components/Books/BookList.scss index 94a22c85..c2110823 100644 --- a/web/src/components/Books/BookList.scss +++ b/web/src/components/Books/BookList.scss @@ -24,6 +24,7 @@ list-style: none; padding-left: 0; margin-bottom: 0; + margin-top: rem(20px); border-radius: 2px; border: 1px solid $border-color; } diff --git a/web/src/components/Books/BookList.tsx b/web/src/components/Books/BookList.tsx index e735625e..a46ad523 100644 --- a/web/src/components/Books/BookList.tsx +++ b/web/src/components/Books/BookList.tsx @@ -27,7 +27,7 @@ import styles from './BookList.scss'; function Placeholder() { const ret = []; - for (let i = 0; i < 12; i++) { + for (let i = 0; i < 8; i++) { ret.push(); } diff --git a/web/src/components/Common/Auth.scss b/web/src/components/Common/Auth.scss index bbd413cc..52df8338 100644 --- a/web/src/components/Common/Auth.scss +++ b/web/src/components/Common/Auth.scss @@ -22,7 +22,7 @@ @import '../App/rem'; .page { - background: $light-gray; + background: $lighter-gray; text-align: center; min-height: 100vh; padding: 50px 0; diff --git a/web/src/components/Common/Button/index.tsx b/web/src/components/Common/Button/index.tsx index 557ef712..d970b13d 100644 --- a/web/src/components/Common/Button/index.tsx +++ b/web/src/components/Common/Button/index.tsx @@ -29,7 +29,7 @@ type ButtonType = 'button' | 'submit' | 'reset'; interface Props { type: ButtonType; kind: string; - size: string; + size?: string; children: React.ReactNode; id?: string; className?: string; diff --git a/web/src/components/Common/Menu/Menu.scss b/web/src/components/Common/Menu/Menu.scss index 837972d5..978dd091 100644 --- a/web/src/components/Common/Menu/Menu.scss +++ b/web/src/components/Common/Menu/Menu.scss @@ -19,4 +19,9 @@ .content { box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08); + z-index: 1; +} + +.wrapper { + position: relative; } diff --git a/web/src/components/Common/Menu/index.tsx b/web/src/components/Common/Menu/index.tsx index db51d038..8b505d64 100644 --- a/web/src/components/Common/Menu/index.tsx +++ b/web/src/components/Common/Menu/index.tsx @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -import React, { Fragment, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import classnames from 'classnames'; import { KEYCODE_UP, KEYCODE_DOWN } from 'jslib/helpers/keyboard'; @@ -44,12 +44,12 @@ const Content: React.FunctionComponent = ({ headerContent }) => { return ( - - {headerContent} +
+
{headerContent}
- +
); }; @@ -73,7 +73,7 @@ interface MenuProps { optRefs: React.MutableRefObject[]; triggerContent: React.ReactNode; triggerClassName?: string; - contentClassName: string; + contentClassName?: string; alignment: Alignment; direction: Direction; headerContent?: React.ReactNode; @@ -81,6 +81,7 @@ interface MenuProps { menuId: string; triggerId: string; disabled?: boolean; + defaultCurrentOptionIdx?: number; } const Menu: React.FunctionComponent = ({ @@ -97,9 +98,12 @@ const Menu: React.FunctionComponent = ({ wrapperClassName, menuId, triggerId, - disabled + disabled, + defaultCurrentOptionIdx = 0 }) => { - const [currentOptionIdx, setCurrentOptionIdx] = useState(0); + const [currentOptionIdx, setCurrentOptionIdx] = useState( + defaultCurrentOptionIdx + ); const [contentEl, setContentEl] = useState(null); useEffect(() => { @@ -111,9 +115,9 @@ const Menu: React.FunctionComponent = ({ el.focus(); } } else { - setCurrentOptionIdx(0); + setCurrentOptionIdx(defaultCurrentOptionIdx); } - }, [isOpen, currentOptionIdx, optRefs]); + }, [isOpen, currentOptionIdx, defaultCurrentOptionIdx, optRefs]); useEventListener(contentEl, 'keydown', e => { const { keyCode } = e; @@ -173,7 +177,7 @@ const Menu: React.FunctionComponent = ({ ); }} contentClassName={classnames(styles.content, contentClassName)} - wrapperClassName={wrapperClassName} + wrapperClassName={classnames(styles.wrapper, wrapperClassName)} isOpen={isOpen} setIsOpen={setIsOpen} alignment={alignment} diff --git a/web/src/components/Common/MobileMenu.scss b/web/src/components/Common/MobileMenu.scss index a0962022..34914569 100644 --- a/web/src/components/Common/MobileMenu.scss +++ b/web/src/components/Common/MobileMenu.scss @@ -65,6 +65,10 @@ &:hover { color: $white; } + + &.active { + color: $active; + } } .item { diff --git a/web/src/components/Common/MobileMenu.tsx b/web/src/components/Common/MobileMenu.tsx index f4ea92b1..4090eb4e 100644 --- a/web/src/components/Common/MobileMenu.tsx +++ b/web/src/components/Common/MobileMenu.tsx @@ -18,7 +18,7 @@ import React from 'react'; import classnames from 'classnames'; -import { Link } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { SettingSections, @@ -61,17 +61,22 @@ const MobileMenu: React.FunctionComponent = ({ onDismiss, isOpen }) => {
  • - Settings - +
  • - + Repetition - +
  • . + */ + +/* eslint-disable react/no-danger */ + +import React from 'react'; +import classnames from 'classnames'; + +import { NoteData } from 'jslib/operations/types'; +import { excerpt } from 'web/libs/string'; +import { tokenize, TokenKind } from 'web/libs/fts/lexer'; +import { parseMarkdown } from '../../../helpers/markdown'; +import styles from './Note.scss'; + +function formatFTSSelection(content: string): string { + if (content.indexOf('') === -1) { + return content; + } + + const tokens = tokenize(content); + + let output = ''; + let buf = []; + + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + + if (t.kind === TokenKind.hlBegin || t.kind === TokenKind.eol) { + output += buf.join(''); + + buf = []; + } else if (t.kind === TokenKind.hlEnd) { + output += ` + ${buf.join('')} + `; + + buf = []; + } else { + buf.push(t.value); + } + } + + return output; +} + +function formatContent(content: string): string { + const formatted = formatFTSSelection(content); + return parseMarkdown(formatted); +} + +interface Props { + collapsed?: boolean; + note: NoteData; +} + +const Content: React.SFC = ({ note, collapsed }) => { + return ( +
    + {collapsed ? ( +
    + {excerpt(note.content, 100)} +
    + ) : ( +
    + )} +
    + ); +}; + +export default Content; diff --git a/web/src/components/Common/Note/Footer.tsx b/web/src/components/Common/Note/Footer.tsx new file mode 100644 index 00000000..cb60bd48 --- /dev/null +++ b/web/src/components/Common/Note/Footer.tsx @@ -0,0 +1,75 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; + +import { NoteData } from 'jslib/operations/types'; +import Time from '../../Common/Time'; +import { nanosecToMillisec } from '../../../helpers/time'; +import formatTime from '../../../helpers/time/format'; +import { timeAgo } from '../../../helpers/time'; +import styles from './Note.scss'; + +function formatAddedOn(ts: number): string { + const ms = nanosecToMillisec(ts); + const d = new Date(ms); + + return formatTime(d, '%MMMM %DD, %YYYY'); +} + +interface Props { + note: NoteData; + useTimeAgo: boolean; + collapsed?: boolean; + actions?: React.ReactElement; +} + +const Footer: React.FunctionComponent = ({ + collapsed, + actions, + note, + useTimeAgo +}) => { + if (collapsed) { + return null; + } + + let timeText; + if (useTimeAgo) { + timeText = timeAgo(nanosecToMillisec(note.addedOn)); + } else { + timeText = formatAddedOn(note.addedOn); + } + + return ( +
    +
    + ); +}; + +export default Footer; diff --git a/web/src/components/Note/Content.scss b/web/src/components/Common/Note/Note.scss similarity index 70% rename from web/src/components/Note/Content.scss rename to web/src/components/Common/Note/Note.scss index 69e551e2..eb68529c 100644 --- a/web/src/components/Note/Content.scss +++ b/web/src/components/Common/Note/Note.scss @@ -16,18 +16,25 @@ * along with Dnote. If not, see . */ -@import '../App/responsive'; -@import '../App/theme'; -@import '../App/rem'; -@import '../App/font'; +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/rem'; +@import '../../App/font'; .frame { - // margin: 0 rem(12px); - @include breakpoint(sm) { - // margin: 0 rem(20px); - } box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); background: white; + + &.collapsed { + .book-label { + // control the coloro of ellipsis when overflown + color: $light-gray; + } + + .book-label a { + color: $light-gray; + } + } } .header { @@ -47,37 +54,19 @@ vertical-align: middle; } -.book-label { - @include font-size('medium'); - font-weight: 600; - display: inline-block; - margin-left: rem(12px); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - max-width: rem(200px); - @include breakpoint(sm) { - max-width: rem(200px); - } - @include breakpoint(md) { - max-width: rem(420px); - } - @include breakpoint(lg) { - max-width: rem(600px); - } - - .book-label-link { - color: inherit; - } +.content-wrapper { + padding: rem(12px) rem(16px); } .content { word-wrap: break-word; - padding: rem(12px) rem(16px); word-break: break-all; } +.collapsed-content { + color: $light-gray; +} + .footer { display: flex; justify-content: space-between; @@ -86,30 +75,29 @@ padding: rem(12px) rem(16px); } -$footer-text-color: #8c8c8c; - .ts { - color: $footer-text-color; -} - -.actions { - display: flex; -} - -.action { - color: $footer-text-color; - - &:hover { - color: $link-hover; - text-decoration: underline; - } - - & ~ & { - margin-left: rem(12px); - } + color: $light-gray; } .match { display: inline-block; background: #f7f77d; } + +.book-label { + @include font-size('medium'); + font-weight: 600; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: $black; + + a { + color: inherit; + + &:hover { + color: inherit; + } + } +} diff --git a/web/src/components/Note/Placeholder.scss b/web/src/components/Common/Note/Placeholder.scss similarity index 92% rename from web/src/components/Note/Placeholder.scss rename to web/src/components/Common/Note/Placeholder.scss index 2fec4b39..4f181ef7 100644 --- a/web/src/components/Note/Placeholder.scss +++ b/web/src/components/Common/Note/Placeholder.scss @@ -16,10 +16,10 @@ * along with Dnote. If not, see . */ -@import '../App/responsive'; -@import '../App/theme'; -@import '../App/rem'; -@import '../App/font'; +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/rem'; +@import '../../App/font'; .title { width: rem(200px); diff --git a/web/src/components/Note/Placeholder.tsx b/web/src/components/Common/Note/Placeholder.tsx similarity index 82% rename from web/src/components/Note/Placeholder.tsx rename to web/src/components/Common/Note/Placeholder.tsx index 654f5c5f..fa5972c3 100644 --- a/web/src/components/Note/Placeholder.tsx +++ b/web/src/components/Common/Note/Placeholder.tsx @@ -20,17 +20,19 @@ import React from 'react'; import classnames from 'classnames'; import styles from './Placeholder.scss'; -import noteStyles from './Content.scss'; +import noteStyles from './Note.scss'; -interface Props {} +interface Props { + wrapperClassName?: string; +} -const Placeholder: React.FunctionComponent = () => { +const Placeholder: React.FunctionComponent = ({ wrapperClassName }) => { return ( -
    +
    -
    +
    diff --git a/web/src/components/Common/Note/index.tsx b/web/src/components/Common/Note/index.tsx new file mode 100644 index 00000000..c93682bb --- /dev/null +++ b/web/src/components/Common/Note/index.tsx @@ -0,0 +1,63 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import classnames from 'classnames'; + +import { NoteData } from 'jslib/operations/types'; +import Content from './Content'; +import Footer from './Footer'; +import styles from './Note.scss'; + +interface Props { + note: NoteData; + header: React.ReactElement; + footerActions?: React.ReactElement; + headerRight?: React.ReactElement; + collapsed?: boolean; + footerUseTimeAgo?: boolean; +} + +const Note: React.FunctionComponent = ({ + note, + footerActions, + collapsed, + header, + footerUseTimeAgo = false +}) => { + return ( +
    + {header} + + + +
    +
    + ); +}; + +export default React.memo(Note); diff --git a/web/src/components/Home/Actions/Paginator.tsx b/web/src/components/Common/PageToolbar/Paginator/PageLink.tsx similarity index 57% rename from web/src/components/Home/Actions/Paginator.tsx rename to web/src/components/Common/PageToolbar/Paginator/PageLink.tsx index fa8a9187..b0f802a4 100644 --- a/web/src/components/Home/Actions/Paginator.tsx +++ b/web/src/components/Common/PageToolbar/Paginator/PageLink.tsx @@ -18,19 +18,22 @@ import React from 'react'; import classnames from 'classnames'; - +import { Location } from 'history'; import { Link } from 'react-router-dom'; -import { getHomePath } from 'web/libs/paths'; -import { useFilters, useSelector } from '../../../store'; -import CaretIcon from '../../Icons/Caret'; + +import CaretIcon from '../../../Icons/Caret'; +import { useFilters } from '../../../../store'; import styles from './Paginator.scss'; -// PER_PAGE is the number of results per page in the response from the backend implementation's API. -// Currently it is fixed. -const PER_PAGE = 30; - type Direction = 'next' | 'prev'; +interface Props { + direction: Direction; + disabled: boolean; + getPath: (page: number) => Location; + className?: string; +} + const renderCaret = (direction: Direction, fill: string) => { return ( { ); }; -interface PageLinkProps { - direction: Direction; - disabled: boolean; - className?: string; -} - -const PageLink: React.FunctionComponent = ({ +const PageLink: React.FunctionComponent = ({ direction, + getPath, disabled, className }) => { @@ -79,10 +77,7 @@ const PageLink: React.FunctionComponent = ({ return ( @@ -91,42 +86,4 @@ const PageLink: React.FunctionComponent = ({ ); }; -interface PaginatorProps {} - -const Paginator: React.FunctionComponent = () => { - const filters = useFilters(); - const { notes } = useSelector(state => { - return { - notes: state.notes - }; - }); - - const hasNext = filters.page * PER_PAGE < notes.total; - const hasPrev = filters.page > 1; - const maxPage = Math.ceil(notes.total / PER_PAGE); - - let currentPage; - if (maxPage > 0) { - currentPage = filters.page; - } else { - currentPage = 0; - } - - return ( - - ); -}; - -export default Paginator; +export default PageLink; diff --git a/web/src/components/Home/Actions/Paginator.scss b/web/src/components/Common/PageToolbar/Paginator/Paginator.scss similarity index 90% rename from web/src/components/Home/Actions/Paginator.scss rename to web/src/components/Common/PageToolbar/Paginator/Paginator.scss index 5776d8c6..09500fb4 100644 --- a/web/src/components/Home/Actions/Paginator.scss +++ b/web/src/components/Common/PageToolbar/Paginator/Paginator.scss @@ -16,10 +16,10 @@ * along with Dnote. If not, see . */ -@import '../../App/responsive'; -@import '../../App/font'; -@import '../../App/theme'; -@import '../../App/rem'; +@import '../../../App/responsive'; +@import '../../../App/font'; +@import '../../../App/theme'; +@import '../../../App/rem'; .wrapper { display: inline-flex; diff --git a/web/src/components/Common/PageToolbar/Paginator/index.tsx b/web/src/components/Common/PageToolbar/Paginator/index.tsx new file mode 100644 index 00000000..fde9a350 --- /dev/null +++ b/web/src/components/Common/PageToolbar/Paginator/index.tsx @@ -0,0 +1,68 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import { Location } from 'history'; + +import PageLink from './PageLink'; +import styles from './Paginator.scss'; + +interface Props { + perPage: number; + total: number; + currentPage: number; + getPath: (page: number) => Location; +} + +function getMaxPage(total: number, perPage: number): number { + if (total === 0) { + return 1; + } + + return Math.ceil(total / perPage); +} + +const Paginator: React.FunctionComponent = ({ + perPage, + total, + currentPage, + getPath +}) => { + const hasNext = currentPage * perPage < total; + const hasPrev = currentPage > 1; + const maxPage = getMaxPage(total, perPage); + + return ( + + ); +}; + +export default Paginator; diff --git a/web/src/components/Common/PageToolbar/SelectMenu.scss b/web/src/components/Common/PageToolbar/SelectMenu.scss new file mode 100644 index 00000000..7e451350 --- /dev/null +++ b/web/src/components/Common/PageToolbar/SelectMenu.scss @@ -0,0 +1,79 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/font'; +@import '../../App/theme'; +@import '../../App/rem'; + +.trigger { + color: $dark-gray; + + &:hover, + &.trigger-active { + color: $black; + } +} + +.trigger-content { + display: flex; + align-items: center; + @include font-size('small'); +} + +.caret { + margin-left: rem(8px); +} + +.link { + @include font-size('small'); + white-space: pre; + padding: rem(8px) rem(14px); + width: 100%; + display: block; + color: black; + + &:hover { + background: $lighter-gray; + text-decoration: none; + color: #0056b3; + } + + &:not(.disabled):focus { + background: $lighter-gray; + color: #0056b3; + outline: 1px dotted gray; + } +} + +.header { + font-weight: 600; + background: $light; + + @include font-size('small'); + padding: rem(8px) rem(12px); + border-bottom: 1px solid $border-color; +} + +.content { + width: rem(240px); + + @include breakpoint(md) { + width: rem(280px); + } +} diff --git a/web/src/components/Common/PageToolbar/SelectMenu.tsx b/web/src/components/Common/PageToolbar/SelectMenu.tsx new file mode 100644 index 00000000..69bef0ab --- /dev/null +++ b/web/src/components/Common/PageToolbar/SelectMenu.tsx @@ -0,0 +1,90 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import classnames from 'classnames'; + +import Menu, { MenuOption } from '../../Common/Menu'; +import { Alignment, Direction } from '../../Common/Menu/types'; +import styles from './SelectMenu.scss'; + +interface Props { + defaultCurrentOptionIdx: number; + options: MenuOption[]; + optRefs: any[]; + triggerText: string; + isOpen: boolean; + setIsOpen: (boolean) => void; + triggerId: string; + headerText: string; + menuId: string; + alignment: Alignment; + direction: Direction; + + disabled?: boolean; + wrapperClassName?: string; + triggerClassName?: string; +} + +const SelectMenu: React.FunctionComponent = ({ + defaultCurrentOptionIdx, + options, + optRefs, + triggerText, + disabled, + isOpen, + setIsOpen, + headerText, + triggerId, + menuId, + alignment, + direction, + wrapperClassName, + triggerClassName +}) => { + return ( + + {triggerText} + +
    + } + headerContent={
    {headerText}
    } + triggerClassName={classnames(styles.trigger, triggerClassName, { + [styles['trigger-active']]: isOpen + })} + contentClassName={styles.content} + wrapperClassName={wrapperClassName} + alignment={alignment} + direction={direction} + /> + ); +}; + +export default SelectMenu; diff --git a/web/src/components/Home/Actions/Top.scss b/web/src/components/Common/PageToolbar/index.scss similarity index 97% rename from web/src/components/Home/Actions/Top.scss rename to web/src/components/Common/PageToolbar/index.scss index 3cdf1b38..63160f3e 100644 --- a/web/src/components/Home/Actions/Top.scss +++ b/web/src/components/Common/PageToolbar/index.scss @@ -22,8 +22,6 @@ @import '../../App/rem'; .wrapper { - text-align: right; - @include breakpoint(lg) { height: rem(48px); border-radius: rem(4px); diff --git a/web/src/components/Home/Actions/Top.tsx b/web/src/components/Common/PageToolbar/index.tsx similarity index 77% rename from web/src/components/Home/Actions/Top.tsx rename to web/src/components/Common/PageToolbar/index.tsx index 8850f3af..f43334ec 100644 --- a/web/src/components/Home/Actions/Top.tsx +++ b/web/src/components/Common/PageToolbar/index.tsx @@ -19,25 +19,29 @@ import React from 'react'; import classnames from 'classnames'; -import Paginator from './Paginator'; -import styles from './Top.scss'; +import styles from './index.scss'; type Position = 'top' | 'bottom'; interface Props { position?: Position; + wrapperClassName?: string; } -const TopActions: React.FunctionComponent = ({ position }) => { +const PageToolbar: React.FunctionComponent = ({ + position, + wrapperClassName, + children +}) => { return (
    - + {children}
    ); }; -export default TopActions; +export default PageToolbar; diff --git a/web/src/components/Digest/ClearSearchBar.scss b/web/src/components/Digest/ClearSearchBar.scss new file mode 100644 index 00000000..212366f6 --- /dev/null +++ b/web/src/components/Digest/ClearSearchBar.scss @@ -0,0 +1,43 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/variables'; +@import '../App/font'; +@import '../App/rem'; + +.wrapper { + margin-top: rem(12px); + font-weight: 600; +} + +.button { + color: $gray; + + &:hover { + color: $gray; + } +} + +.text { + @include font-size('small'); + + margin-left: rem(4px); + margin-top: rem(2px); +} diff --git a/web/src/components/Digest/ClearSearchBar.tsx b/web/src/components/Digest/ClearSearchBar.tsx new file mode 100644 index 00000000..bd455012 --- /dev/null +++ b/web/src/components/Digest/ClearSearchBar.tsx @@ -0,0 +1,54 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { getDigestPath } from 'web/libs/paths'; +import { SearchParams } from './types'; +import CloseIcon from '../Icons/Close'; +import styles from './ClearSearchBar.scss'; + +interface Props { + params: SearchParams; + digestUUID: string; +} + +const ClearSearchBar: React.FunctionComponent = ({ + params, + digestUUID +}) => { + const isActive = params.sort !== '' || params.status !== ''; + + if (!isActive) { + return null; + } + + return ( +
    + + + + Clear the current filters, and sorts + + +
    + ); +}; + +export default ClearSearchBar; diff --git a/web/src/components/Digest/Digest.scss b/web/src/components/Digest/Digest.scss new file mode 100644 index 00000000..39cba403 --- /dev/null +++ b/web/src/components/Digest/Digest.scss @@ -0,0 +1,69 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/variables'; +@import '../App/font'; +@import '../App/rem'; + +.item { + text-align: left; + + & ~ & { + margin-top: rem(20px); + } +} + +.wrapper { + margin-top: rem(12px); + + @include breakpoint(lg) { + margin-top: rem(20px); + } +} + +.list { + width: 100%; + list-style: none; + padding-left: 0; + margin-bottom: 0; + display: inline-block; +} + +.action { + color: $light-gray; + + &:hover { + color: $link-hover; + text-decoration: underline; + } + + & ~ & { + margin-left: rem(12px); + } +} + +.error-flash { + margin-top: rem(20px); +} + +.clear-search-bar { + margin-top: rem(12px); + font-weight: 600; +} diff --git a/web/src/components/Digest/Header/Content.scss b/web/src/components/Digest/Header/Content.scss new file mode 100644 index 00000000..268f96f5 --- /dev/null +++ b/web/src/components/Digest/Header/Content.scss @@ -0,0 +1,70 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/variables'; +@import '../../App/font'; +@import '../../App/rem'; + +.meta { + margin-top: rem(4px); + @include font-size('small'); +} + +.sep { + margin: 0 rem(8px); +} + +.header { + display: flex; + flex-direction: column; + align-items: flex-start; + z-index: 1; + margin-bottom: rem(20px); + + padding-top: rem(12px); + + @include breakpoint(md) { + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + background-color: transparent; + } + + @include breakpoint(lg) { + padding-top: 0; + } +} + +.header-container { + z-index: 1; + + &.header-sticky { + background-color: $white; + position: sticky; + top: $header-height; + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.18); + + .header { + padding-top: rem(12px); + padding-bottom: rem(12px); + margin-bottom: 0; + } + } +} diff --git a/web/src/components/Digest/Header/Content.tsx b/web/src/components/Digest/Header/Content.tsx new file mode 100644 index 00000000..72187143 --- /dev/null +++ b/web/src/components/Digest/Header/Content.tsx @@ -0,0 +1,86 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { Fragment } from 'react'; + +import { pluralize } from 'web/libs/string'; +import { DigestData, DigestNoteData } from 'jslib/operations/types'; +import Time from '../../Common/Time'; +import formatTime from '../../../helpers/time/format'; +import { getDigestTitle } from '../helpers'; +import Progress from './Progress'; +import styles from './Content.scss'; + +function formatCreatedAt(d: Date) { + const now = new Date(); + + const currentYear = now.getFullYear(); + const year = d.getFullYear(); + + if (currentYear === year) { + return formatTime(d, '%MMM %DD'); + } + + return formatTime(d, '%MMM %DD, %YYYY'); +} + +function getViewedCount(notes: DigestNoteData[]): number { + let count = 0; + + for (let i = 0; i < notes.length; ++i) { + const n = notes[i]; + + if (n.isReviewed) { + count++; + } + } + + return count; +} + +interface Props { + digest: DigestData; +} + +const Content: React.FunctionComponent = ({ digest }) => { + const viewedCount = getViewedCount(digest.notes); + + return ( + +
    +

    {getDigestTitle(digest)}

    +
    + Contains {pluralize('note', digest.notes.length, true)} + · + Created on{' '} +
    +
    + + +
    + ); +}; + +export default Content; diff --git a/web/src/components/Digest/Header/Placeholder.scss b/web/src/components/Digest/Header/Placeholder.scss new file mode 100644 index 00000000..28a4ec2e --- /dev/null +++ b/web/src/components/Digest/Header/Placeholder.scss @@ -0,0 +1,49 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/variables'; +@import '../../App/font'; +@import '../../App/rem'; + +.wrapper { + position: relative; + width: 100%; +} + +.title { + height: rem(24px); + width: 100%; + + @include breakpoint(md) { + height: rem(32px); + width: rem(400px); + } +} + +.meta { + width: rem(80px); + height: rem(16px); + margin-top: rem(12px); + + @include breakpoint(md) { + height: rem(20px); + width: rem(320px); + } +} diff --git a/web/src/components/Digest/Header/Placeholder.tsx b/web/src/components/Digest/Header/Placeholder.tsx new file mode 100644 index 00000000..39b800cd --- /dev/null +++ b/web/src/components/Digest/Header/Placeholder.tsx @@ -0,0 +1,36 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import classnames from 'classnames'; + +import styles from './Placeholder.scss'; + +interface Props {} + +const HeaderPlaceholder: React.FunctionComponent = () => { + return ( +
    +
    + +
    +
    + ); +}; + +export default HeaderPlaceholder; diff --git a/web/src/components/Digest/Header/Progress.scss b/web/src/components/Digest/Header/Progress.scss new file mode 100644 index 00000000..ccc3a4ef --- /dev/null +++ b/web/src/components/Digest/Header/Progress.scss @@ -0,0 +1,75 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/variables'; +@import '../../App/font'; +@import '../../App/rem'; + +.wrapper { + margin-top: rem(4px); + + display: flex; + align-items: center; + width: 100%; + + @include breakpoint(md) { + width: rem(220px); + margin-top: rem(0); + margin-bottom: rem(8px); + + display: initial; + align-items: initial; + width: auto; + } +} + +.bar-wrapper { + display: flex; + height: 8px; + overflow: hidden; + background-color: #c5c6c8; + border-radius: 4px; + width: rem(120px); + margin-left: rem(12px); + + @include breakpoint(md) { + width: rem(220px); + margin-top: rem(4px); + margin-left: 0; + } +} + +.bar { + transition: width 0.5s ease-out 0s; + width: 0%; + background: $first; +} + +.perc { + font-style: italic; +} + +.caption { + @include font-size('small'); + + &.caption-strong { + font-weight: 600; + } +} diff --git a/web/src/components/Digest/Header/Progress.tsx b/web/src/components/Digest/Header/Progress.tsx new file mode 100644 index 00000000..1be2fc1d --- /dev/null +++ b/web/src/components/Digest/Header/Progress.tsx @@ -0,0 +1,74 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import classnames from 'classnames'; + +import { pluralize } from 'web/libs/string'; +import styles from './Progress.scss'; + +interface Props { + total: number; + current: number; +} + +function calcPercentage(current: number, total: number): number { + if (total === 0) { + return 100; + } + + return (current / total) * 100; +} + +function getCaption(current, total): string { + if (current === total && total !== 0) { + return 'Review completed'; + } + + return `${current} of ${total} ${pluralize('note', current)} reviewed`; +} + +const Progress: React.FunctionComponent = ({ total, current }) => { + const isComplete = current === total; + const perc = calcPercentage(current, total); + const width = `${perc}%`; + + return ( +
    +
    + {getCaption(current, total)}{' '} + ({perc.toFixed(0)}%) +
    +
    +
    +
    +
    + ); +}; + +export default Progress; diff --git a/web/src/components/Digest/Header/index.tsx b/web/src/components/Digest/Header/index.tsx new file mode 100644 index 00000000..466f5c7f --- /dev/null +++ b/web/src/components/Digest/Header/index.tsx @@ -0,0 +1,71 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState } from 'react'; +import classnames from 'classnames'; + +import { DigestData } from 'jslib/operations/types'; +import { useEventListener } from 'web/libs/hooks'; +import { getScrollYPos } from 'web/libs/dom'; +import Placeholder from './Placeholder'; +import Content from './Content'; +import styles from './Content.scss'; + +interface Props { + isFetched: boolean; + digest: DigestData; +} + +const stickyThresholdY = 24; + +function checkSticky(y: number): boolean { + return y > stickyThresholdY; +} + +const Header: React.FunctionComponent = ({ digest, isFetched }) => { + const [isSticky, setIsSticky] = useState(false); + + function handleScroll() { + const y = getScrollYPos(); + const nextSticky = checkSticky(y); + + if (nextSticky) { + setIsSticky(true); + } else if (!nextSticky) { + setIsSticky(false); + } + } + + useEventListener(document, 'scroll', handleScroll); + + return ( +
    +
    +
    + {isFetched ? : } +
    +
    +
    + ); +}; + +export default Header; diff --git a/web/src/components/Digest/NoteItem/Header.scss b/web/src/components/Digest/NoteItem/Header.scss new file mode 100644 index 00000000..78e1151b --- /dev/null +++ b/web/src/components/Digest/NoteItem/Header.scss @@ -0,0 +1,71 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/variables'; +@import '../../App/font'; +@import '../../App/rem'; + +.wrapper { + position: relative; +} + +.title { + height: rem(24px); + width: 100%; + + @include breakpoint(md) { + height: rem(32px); + width: rem(400px); + } +} + +.meta { + width: rem(80px); + height: rem(16px); + margin-top: rem(12px); + + @include breakpoint(md) { + height: rem(20px); + width: rem(320px); + } +} + +.caret-collapsed { + transform: rotate(270deg); +} + +.header-action { + align-self: stretch; +} + +.book-label { + max-width: rem(200px); + margin-left: rem(8px); + + @include breakpoint(sm) { + max-width: rem(200px); + } + @include breakpoint(md) { + max-width: rem(420px); + } + @include breakpoint(lg) { + max-width: rem(600px); + } +} diff --git a/web/src/components/Digest/NoteItem/Header.tsx b/web/src/components/Digest/NoteItem/Header.tsx new file mode 100644 index 00000000..77aa044d --- /dev/null +++ b/web/src/components/Digest/NoteItem/Header.tsx @@ -0,0 +1,95 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import classnames from 'classnames'; + +import { DigestNoteData } from 'jslib/operations/types'; +import { getHomePath } from 'web/libs/paths'; +import ReviewButton from './ReviewButton'; +import Button from '../../Common/Button'; +import CaretIcon from '../../Icons/CaretSolid'; + +import noteStyles from '../../Common/Note/Note.scss'; +import styles from './Header.scss'; + +interface Props { + note: DigestNoteData; + setCollapsed: (boolean) => void; + onSetReviewed: (string, boolean) => Promise; + setErrMessage: (string) => void; + collapsed: boolean; +} + +const Header: React.FunctionComponent = ({ + note, + collapsed, + setCollapsed, + onSetReviewed, + setErrMessage +}) => { + let fill; + if (collapsed) { + fill = '#8c8c8c'; + } else { + fill = '#000000'; + } + + return ( +
    +
    + + +

    + + {note.book.label} + +

    +
    + +
    + +
    +
    + ); +}; + +export default Header; diff --git a/web/src/components/Digest/NoteItem/ReviewButton.scss b/web/src/components/Digest/NoteItem/ReviewButton.scss new file mode 100644 index 00000000..1c5d54c9 --- /dev/null +++ b/web/src/components/Digest/NoteItem/ReviewButton.scss @@ -0,0 +1,39 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/variables'; +@import '../../App/font'; +@import '../../App/rem'; + +.wrapper { + border: 1px solid $border-color; + margin-bottom: 0; + display: flex; + // align-items: center; + padding: rem(4px) rem(8px); + border-radius: rem(4px); + margin-left: rem(12px); +} + +.text { + @include font-size('small'); + margin-left: rem(4px); + user-select: none; +} diff --git a/web/src/components/Digest/NoteItem/ReviewButton.tsx b/web/src/components/Digest/NoteItem/ReviewButton.tsx new file mode 100644 index 00000000..141ec378 --- /dev/null +++ b/web/src/components/Digest/NoteItem/ReviewButton.tsx @@ -0,0 +1,71 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState } from 'react'; +import classnames from 'classnames'; + +import digestStyles from '../Digest.scss'; +import styles from './ReviewButton.scss'; + +interface Props { + noteUUID: string; + isReviewed: boolean; + setCollapsed: (boolean) => void; + onSetReviewed: (string, boolean) => Promise; + setErrMessage: (string) => void; +} + +const ReviewButton: React.FunctionComponent = ({ + noteUUID, + isReviewed, + setCollapsed, + onSetReviewed, + setErrMessage +}) => { + const [checked, setChecked] = useState(isReviewed); + + return ( + + ); +}; + +export default ReviewButton; diff --git a/web/src/components/Digest/NoteItem/index.tsx b/web/src/components/Digest/NoteItem/index.tsx new file mode 100644 index 00000000..90962d34 --- /dev/null +++ b/web/src/components/Digest/NoteItem/index.tsx @@ -0,0 +1,74 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { Fragment, useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { DigestNoteData } from 'jslib/operations/types'; +import { getNotePath } from 'web/libs/paths'; +import Note from '../../Common/Note'; +import Flash from '../../Common/Flash'; +import NoteItemHeader from './Header'; +import styles from '../Digest.scss'; + +interface Props { + note: DigestNoteData; + onSetReviewed: (string, boolean) => Promise; +} + +const NoteItem: React.FunctionComponent = ({ note, onSetReviewed }) => { + const [collapsed, setCollapsed] = useState(note.isReviewed); + const [errorMessage, setErrMessage] = useState(''); + + return ( +
  • + + + + + {errorMessage} + + + } + footerActions={ + + Go to note › + + } + footerUseTimeAgo + /> +
  • + ); +}; + +export default NoteItem; diff --git a/web/src/components/Digest/NoteList.tsx b/web/src/components/Digest/NoteList.tsx new file mode 100644 index 00000000..fccf13ba --- /dev/null +++ b/web/src/components/Digest/NoteList.tsx @@ -0,0 +1,86 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import Helmet from 'react-helmet'; + +import { DigestData } from 'jslib/operations/types'; +import { DigestNoteData } from 'jslib/operations/types'; +import { getDigestTitle } from './helpers'; +import { useDispatch } from '../../store'; +import { setDigestNoteReviewed } from '../../store/digest'; +import Placeholder from '../Common/Note/Placeholder'; +import NoteItem from './NoteItem'; +import styles from './Digest.scss'; + +interface Props { + notes: DigestNoteData[]; + digest: DigestData; + isFetched: boolean; + isFetching: boolean; +} + +const NoteList: React.FunctionComponent = ({ + isFetched, + isFetching, + notes, + digest +}) => { + const dispatch = useDispatch(); + + function handleSetReviewed(noteUUID: string, isReviewed: boolean) { + return dispatch( + setDigestNoteReviewed({ digestUUID: digest.uuid, noteUUID, isReviewed }) + ); + } + + if (isFetching) { + return ( +
    + + + +
    + ); + } + if (!isFetched) { + return null; + } + + return ( +
    + + {`${getDigestTitle(digest)} - Digest`} + + +
      + {notes.map(note => { + return ( + + ); + })} +
    +
    + ); +}; + +export default NoteList; diff --git a/web/src/components/Digest/Toolbar/SortMenu.tsx b/web/src/components/Digest/Toolbar/SortMenu.tsx new file mode 100644 index 00000000..d9aa35a2 --- /dev/null +++ b/web/src/components/Digest/Toolbar/SortMenu.tsx @@ -0,0 +1,121 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState, useRef } from 'react'; +import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; +import classnames from 'classnames'; + +import { parseSearchString } from 'jslib/helpers/url'; +import { getDigestPath } from 'web/libs/paths'; +import { blacklist } from 'jslib/helpers/obj'; +import SelectMenu from '../../Common/PageToolbar/SelectMenu'; +import selectMenuStyles from '../../Common/PageToolbar/SelectMenu.scss'; +import { Sort } from '../types'; +import styles from './Toolbar.scss'; + +interface Props extends RouteComponentProps { + digestUUID: string; + sort: Sort; + disabled?: boolean; +} + +const SortMenu: React.FunctionComponent = ({ + digestUUID, + sort, + disabled, + location +}) => { + const [isOpen, setIsOpen] = useState(false); + const optRefs = [useRef(null), useRef(null)]; + const searchObj = parseSearchString(location.search); + + const options = [ + { + name: 'newest', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[0]} + tabIndex={-1} + > + Newest + + ) + }, + { + name: 'oldest', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[1]} + tabIndex={-1} + > + Oldest + + ) + } + ]; + + const isActive = sort === Sort.Oldest; + + let defaultCurrentOptionIdx: number; + let sortText: string; + if (sort === Sort.Oldest) { + defaultCurrentOptionIdx = 1; + sortText = 'Oldest'; + } else { + defaultCurrentOptionIdx = 0; + sortText = 'Newest'; + } + + return ( + + ); +}; + +export default withRouter(SortMenu); diff --git a/web/src/components/Digest/Toolbar/StatusMenu.tsx b/web/src/components/Digest/Toolbar/StatusMenu.tsx new file mode 100644 index 00000000..002b6c4d --- /dev/null +++ b/web/src/components/Digest/Toolbar/StatusMenu.tsx @@ -0,0 +1,144 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState, useRef } from 'react'; +import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; +import classnames from 'classnames'; + +import { getDigestPath } from 'web/libs/paths'; +import { parseSearchString } from 'jslib/helpers/url'; +import { blacklist } from 'jslib/helpers/obj'; +import SelectMenu from '../../Common/PageToolbar/SelectMenu'; +import selectMenuStyles from '../../Common/PageToolbar/SelectMenu.scss'; +import { Status } from '../types'; +import styles from './Toolbar.scss'; + +interface Props extends RouteComponentProps { + digestUUID: string; + status: Status; + disabled?: boolean; +} + +const StatusMenu: React.FunctionComponent = ({ + digestUUID, + status, + disabled, + location +}) => { + const [isOpen, setIsOpen] = useState(false); + const optRefs = [useRef(null), useRef(null), useRef(null)]; + const searchObj = parseSearchString(location.search); + + const options = [ + { + name: 'all', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[0]} + tabIndex={-1} + > + All + + ) + }, + { + name: 'unreviewed', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[1]} + tabIndex={-1} + > + Unreviewed + + ) + }, + { + name: 'reviewed', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[2]} + tabIndex={-1} + > + Reviewed + + ) + } + ]; + + const isActive = status === Status.Reviewed || status === Status.Unreviewed; + + let defaultCurrentOptionIdx: number; + let statusText: string; + if (status === Status.Reviewed) { + defaultCurrentOptionIdx = 2; + statusText = 'Reviewed'; + } else if (status === Status.Unreviewed) { + defaultCurrentOptionIdx = 1; + statusText = 'Unreviewed'; + } else { + defaultCurrentOptionIdx = 0; + statusText = 'All'; + } + + return ( + + ); +}; + +export default withRouter(StatusMenu); diff --git a/web/src/components/Digest/Toolbar/Toolbar.scss b/web/src/components/Digest/Toolbar/Toolbar.scss new file mode 100644 index 00000000..ac32e89c --- /dev/null +++ b/web/src/components/Digest/Toolbar/Toolbar.scss @@ -0,0 +1,47 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/variables'; +@import '../../App/font'; +@import '../../App/rem'; + +.wrapper { + display: flex; + justify-content: flex-start; + align-items: center; + margin-top: rem(12px); + + @include breakpoint(md) { + justify-content: flex-end; + } + + @include breakpoint(lg) { + padding: 0 rem(16px); + margin-top: 0; + } +} + +.active-menu-trigger { + font-weight: 600; +} + +.menu-trigger ~ .menu-trigger { + margin-left: rem(12px); +} diff --git a/web/src/components/Digest/Toolbar/index.tsx b/web/src/components/Digest/Toolbar/index.tsx new file mode 100644 index 00000000..424fb3c0 --- /dev/null +++ b/web/src/components/Digest/Toolbar/index.tsx @@ -0,0 +1,53 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; + +import PageToolbar from '../../Common/PageToolbar'; +import SortMenu from './SortMenu'; +import StatusMenu from './StatusMenu'; +import { Sort, Status } from '../types'; +import styles from './Toolbar.scss'; + +interface Props { + digestUUID: string; + sort: Sort; + status: Status; + isFetched: boolean; +} + +const Toolbar: React.FunctionComponent = ({ + digestUUID, + sort, + status, + isFetched +}) => { + return ( + + + + + + ); +}; + +export default Toolbar; diff --git a/web/src/components/Digest/helpers.ts b/web/src/components/Digest/helpers.ts new file mode 100644 index 00000000..109cbdc2 --- /dev/null +++ b/web/src/components/Digest/helpers.ts @@ -0,0 +1,24 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import { DigestData } from 'jslib/operations/types'; + +// getDigestTitle returns a title for the digest +export function getDigestTitle(digest: DigestData) { + return `${digest.repetitionRule.title} #${digest.version}`; +} diff --git a/web/src/components/Digest/index.tsx b/web/src/components/Digest/index.tsx new file mode 100644 index 00000000..dff1e9f5 --- /dev/null +++ b/web/src/components/Digest/index.tsx @@ -0,0 +1,165 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useEffect } from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import Helmet from 'react-helmet'; +import { Location } from 'history'; + +import { DigestNoteData } from 'jslib/operations/types'; +import { parseSearchString } from 'jslib/helpers/url'; +import { usePrevious } from 'web/libs/hooks'; +import { Sort, Status, SearchParams } from './types'; +import { getDigest } from '../../store/digest'; +import { useDispatch, useSelector } from '../../store'; +import Header from './Header'; +import Toolbar from './Toolbar'; +import NoteList from './NoteList'; +import Flash from '../Common/Flash'; +import ClearSearchBar from './ClearSearchBar'; +import styles from './Digest.scss'; + +function useFetchData(digestUUID: string) { + const dispatch = useDispatch(); + + const { digest } = useSelector(state => { + return { + digest: state.digest + }; + }); + + const prevDigestUUID = usePrevious(digestUUID); + + useEffect(() => { + if (!digest.isFetched || (digestUUID && prevDigestUUID !== digestUUID)) { + dispatch(getDigest(digestUUID)); + } + }, [dispatch, digestUUID, digest.isFetched, prevDigestUUID]); +} + +interface Match { + digestUUID: string; +} + +interface Props extends RouteComponentProps {} + +function getNotes(notes: DigestNoteData[], p: SearchParams): DigestNoteData[] { + const filtered = notes.filter(note => { + if (p.status === Status.Reviewed) { + return note.isReviewed; + } + if (p.status === Status.Unreviewed) { + return !note.isReviewed; + } + + return true; + }); + + return filtered.concat().sort((i, j) => { + if (p.sort === Sort.Oldest) { + return new Date(i.createdAt).getTime() - new Date(j.createdAt).getTime(); + } + + return new Date(j.createdAt).getTime() - new Date(i.createdAt).getTime(); + }); +} + +function parseSearchParams(location: Location): SearchParams { + const searchObj = parseSearchString(location.search); + + let sort: Sort; + if (searchObj.sort === Sort.Oldest) { + sort = Sort.Oldest; + } else { + sort = Sort.Newest; + } + + let status: Status; + if (searchObj.status === Status.Unreviewed) { + status = Status.Unreviewed; + } else if (searchObj.status === Status.Reviewed) { + status = Status.Reviewed; + } else { + status = Status.All; + } + + return { + sort, + status, + books: [] + }; +} + +const Digest: React.FunctionComponent = ({ location, match }) => { + const { digestUUID } = match.params; + + useFetchData(digestUUID); + + const { digest } = useSelector(state => { + return { + digest: state.digest + }; + }); + + const params = parseSearchParams(location); + const notes = getNotes(digest.data.notes, params); + + return ( +
    + + Digest + + +
    + +
    + +
    + +
    + +
    + +
    + + Error getting digest: {digest.errorMessage} + +
    + +
    + +
    +
    + ); +}; + +export default withRouter(Digest); diff --git a/web/src/components/Digest/types.ts b/web/src/components/Digest/types.ts new file mode 100644 index 00000000..1d64f0bf --- /dev/null +++ b/web/src/components/Digest/types.ts @@ -0,0 +1,36 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +// Sort is a set of possible values for sort query parameters +export enum Sort { + Newest = '', + Oldest = 'created-asc' +} + +// Status is a set of possible values for status query parameters +export enum Status { + All = '', + Unreviewed = 'unreviewed', + Reviewed = 'reviewed' +} + +export interface SearchParams { + sort: Sort; + status: Status; + books: string[]; +} diff --git a/web/src/components/Digests/Item.scss b/web/src/components/Digests/Item.scss new file mode 100644 index 00000000..193235ef --- /dev/null +++ b/web/src/components/Digests/Item.scss @@ -0,0 +1,67 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/rem'; +@import '../App/font'; + +.wrapper { + background: white; + position: relative; + border-bottom: 1px solid $border-color; + + &:first-child { + border-top-left-radius: rem(4px); + border-top-right-radius: rem(4px); + } + &:last-child { + border-bottom-left-radius: rem(4px); + border-bottom-right-radius: rem(4px); + } + + &.unread { + .title { + font-weight: 600; + } + } + &.read { + .title { + color: $gray; + } + } +} + +.link { + color: $black; + display: flex; + justify-content: space-between; + padding: rem(12px) rem(16px); + border: 2px solid transparent; + + &:hover { + text-decoration: none; + background: $light-blue; + color: inherit; + } +} + +.ts { + color: $gray; + @include font-size('small'); +} diff --git a/web/src/components/Digests/Item.tsx b/web/src/components/Digests/Item.tsx new file mode 100644 index 00000000..b0e395c6 --- /dev/null +++ b/web/src/components/Digests/Item.tsx @@ -0,0 +1,62 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import classnames from 'classnames'; + +import { DigestData } from 'jslib/operations/types'; +import { getDigestPath } from 'web/libs/paths'; +import Time from '../Common/Time'; +import { timeAgo } from '../../helpers/time'; +import styles from './Item.scss'; + +interface Props { + item: DigestData; +} + +const Item: React.FunctionComponent = ({ item }) => { + const createdAt = new Date(item.createdAt); + + return ( +
  • + + + {item.repetitionRule.title} #{item.version} + +
  • + ); +}; + +export default Item; diff --git a/web/src/components/Digests/List.scss b/web/src/components/Digests/List.scss new file mode 100644 index 00000000..3e283d8c --- /dev/null +++ b/web/src/components/Digests/List.scss @@ -0,0 +1,31 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/rem'; +@import '../App/font'; + +.wrapper { + box-shadow: 0 0 8px rgba(0, 0, 0, 0.14); + border-radius: rem(4px); + + @include breakpoint(md) { + margin-top: rem(16px); + } +} diff --git a/web/src/components/Digests/List.tsx b/web/src/components/Digests/List.tsx new file mode 100644 index 00000000..71f53cdb --- /dev/null +++ b/web/src/components/Digests/List.tsx @@ -0,0 +1,61 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import classnames from 'classnames'; + +import { DigestData } from 'jslib/operations/types'; +import { getRange } from 'jslib/helpers/arr'; +import Item from './Item'; +import Placeholder from './Placeholder'; +import styles from './List.scss'; + +interface Props { + isFetched: boolean; + isFetching: boolean; + items: DigestData[]; +} + +const List: React.FunctionComponent = ({ + items, + isFetched, + isFetching +}) => { + if (isFetching) { + return ( +
    + {getRange(10).map(key => { + return ; + })} +
    + ); + } + if (!isFetched) { + return null; + } + + return ( +
      + {items.map(item => { + return ; + })} +
    + ); +}; + +export default List; diff --git a/web/src/components/Digests/Placeholder.scss b/web/src/components/Digests/Placeholder.scss new file mode 100644 index 00000000..c348596d --- /dev/null +++ b/web/src/components/Digests/Placeholder.scss @@ -0,0 +1,33 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/rem'; + +.wrapper { + padding: rem(12px) rem(16px); +} + +.title { + height: rem(20px); + + @include breakpoint(md) { + width: 152px; + } +} diff --git a/web/src/components/Digests/Placeholder.tsx b/web/src/components/Digests/Placeholder.tsx new file mode 100644 index 00000000..a20d5986 --- /dev/null +++ b/web/src/components/Digests/Placeholder.tsx @@ -0,0 +1,31 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; +import classnames from 'classnames'; + +import itemStyles from './Item.scss'; +import styles from './Placeholder.scss'; + +export default () => { + return ( +
    +
    +
    + ); +}; diff --git a/web/src/components/Digests/Toolbar/StatusMenu.tsx b/web/src/components/Digests/Toolbar/StatusMenu.tsx new file mode 100644 index 00000000..0243a2c2 --- /dev/null +++ b/web/src/components/Digests/Toolbar/StatusMenu.tsx @@ -0,0 +1,123 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; + +import { getDigestsPath } from 'web/libs/paths'; +import SelectMenu from '../../Common/PageToolbar/SelectMenu'; +import selectMenuStyles from '../../Common/PageToolbar/SelectMenu.scss'; +import { Status } from '../types'; +import styles from './Toolbar.scss'; + +interface Props { + status: Status; + disabled?: boolean; +} + +const StatusMenu: React.FunctionComponent = ({ status, disabled }) => { + const [isOpen, setIsOpen] = useState(false); + const optRefs = [useRef(null), useRef(null), useRef(null)]; + + const options = [ + { + name: 'all', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[0]} + tabIndex={-1} + > + All + + ) + }, + { + name: 'unread', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[1]} + tabIndex={-1} + > + Unread + + ) + }, + { + name: 'read', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[2]} + tabIndex={-1} + > + Read + + ) + } + ]; + + let defaultCurrentOptionIdx: number; + let triggerText: string; + if (status === Status.Read) { + defaultCurrentOptionIdx = 2; + triggerText = 'Read'; + } else if (status === Status.Unread) { + defaultCurrentOptionIdx = 1; + triggerText = 'Unread'; + } else { + defaultCurrentOptionIdx = 0; + triggerText = 'All'; + } + + return ( + + ); +}; + +export default StatusMenu; diff --git a/web/src/components/Digests/Toolbar/Toolbar.scss b/web/src/components/Digests/Toolbar/Toolbar.scss new file mode 100644 index 00000000..6ba44bf6 --- /dev/null +++ b/web/src/components/Digests/Toolbar/Toolbar.scss @@ -0,0 +1,33 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/responsive'; +@import '../../App/theme'; +@import '../../App/rem'; +@import '../../App/font'; + +.toolbar { + display: flex; + justify-content: space-between; +} + +.select-menu-wrapper { + display: flex; + align-items: center; + margin-left: rem(8px); +} diff --git a/web/src/components/Digests/Toolbar/index.tsx b/web/src/components/Digests/Toolbar/index.tsx new file mode 100644 index 00000000..7cb40c62 --- /dev/null +++ b/web/src/components/Digests/Toolbar/index.tsx @@ -0,0 +1,53 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React from 'react'; + +import { getDigestsPath } from 'web/libs/paths'; +import PageToolbar from '../../Common/PageToolbar'; +import Paginator from '../../Common/PageToolbar/Paginator'; +import StatusMenu from './StatusMenu'; +import { Status } from '../types'; +import styles from './Toolbar.scss'; + +interface Props { + total: number; + page: number; + status: Status; +} + +const PER_PAGE = 30; + +const Toolbar: React.FunctionComponent = ({ total, page, status }) => { + return ( + + + + { + return getDigestsPath({ page: p }); + }} + /> + + ); +}; + +export default Toolbar; diff --git a/web/src/components/Digests/index.tsx b/web/src/components/Digests/index.tsx new file mode 100644 index 00000000..007b48b5 --- /dev/null +++ b/web/src/components/Digests/index.tsx @@ -0,0 +1,91 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useEffect } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import Helmet from 'react-helmet'; + +import { usePrevious } from 'web/libs/hooks'; +import { parseSearchString } from 'jslib/helpers/url'; +import { useDispatch, useSelector } from '../../store'; +import { getDigests } from '../../store/digests'; +import { Status } from './types'; +import Flash from '../Common/Flash'; +import List from './List'; +import Toolbar from './Toolbar'; + +function useFetchDigests(params: { page: number; status: Status }) { + const dispatch = useDispatch(); + + const prevParams = usePrevious(params); + + useEffect(() => { + if ( + !prevParams || + prevParams.page !== params.page || + prevParams.status !== params.status + ) { + dispatch(getDigests(params)); + } + }, [dispatch, params, prevParams]); +} + +interface Props extends RouteComponentProps {} + +const Digests: React.FunctionComponent = ({ location }) => { + const { digests } = useSelector(state => { + return { + digests: state.digests + }; + }); + const { page, status } = parseSearchString(location.search); + useFetchDigests({ + page: page || 1, + status + }); + + return ( +
    + + Digests + + +
    +
    +

    Digests

    +
    + + + Error getting digests: {digests.errorMessage} + +
    + +
    + + + +
    +
    + ); +}; + +export default Digests; diff --git a/web/src/components/Digests/types.tsx b/web/src/components/Digests/types.tsx new file mode 100644 index 00000000..1d024b2f --- /dev/null +++ b/web/src/components/Digests/types.tsx @@ -0,0 +1,23 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +export enum Status { + All = '', + Read = 'read', + Unread = 'unread' +} diff --git a/web/src/components/EmailPreference/EmailPreference.scss b/web/src/components/EmailPreference/EmailPreference.scss index b2d02e5d..5b174889 100644 --- a/web/src/components/EmailPreference/EmailPreference.scss +++ b/web/src/components/EmailPreference/EmailPreference.scss @@ -25,7 +25,7 @@ text-align: center; height: 100vh; padding: rem(52px) 0; - background: $light-gray; + background: $lighter-gray; } .heading { diff --git a/web/src/components/Header/AccountMenu.scss b/web/src/components/Header/AccountMenu.scss index 26bbffea..1e13e737 100644 --- a/web/src/components/Header/AccountMenu.scss +++ b/web/src/components/Header/AccountMenu.scss @@ -22,7 +22,6 @@ @import '../App/responsive'; .wrapper { - position: relative; display: none; margin-left: rem(20px); align-self: stretch; @@ -88,7 +87,7 @@ color: black; &:hover { - background: $light-gray; + background: $lighter-gray; text-decoration: none; color: #0056b3; } @@ -99,7 +98,7 @@ } &:not(.disabled):focus { - background: $light-gray; + background: $lighter-gray; color: #0056b3; outline: 1px dotted gray; } @@ -108,6 +107,7 @@ .email { font-weight: 600; white-space: normal; + word-break: break-all; } .session-notice-wrapper { diff --git a/web/src/components/Header/Nav/index.tsx b/web/src/components/Header/Nav/index.tsx index 5532b566..3c6f7d40 100644 --- a/web/src/components/Header/Nav/index.tsx +++ b/web/src/components/Header/Nav/index.tsx @@ -19,7 +19,12 @@ import React from 'react'; import classnames from 'classnames'; -import { getNewPath, getBooksPath, getRepetitionsPath } from 'web/libs/paths'; +import { + getNewPath, + getBooksPath, + getRepetitionsPath, + getDigestsPath +} from 'web/libs/paths'; import { Filters, toSearchObj } from 'jslib/helpers/filters'; import Item from './Item'; import styles from './Nav.scss'; @@ -37,7 +42,7 @@ const Nav: React.FunctionComponent = ({ filters }) => { - {/* */} +
); diff --git a/web/src/components/Header/Note/Guest.scss b/web/src/components/Header/Note/Guest.scss index 5233003a..6c7ebf38 100644 --- a/web/src/components/Header/Note/Guest.scss +++ b/web/src/components/Header/Note/Guest.scss @@ -23,7 +23,7 @@ @import '../../App/variables'; .wrapper { - background: $light-gray; + background: $lighter-gray; padding: rem(12px) rem(20px); height: $header-height; z-index: 2; diff --git a/web/src/components/Header/Note/Placeholder.scss b/web/src/components/Header/Note/Placeholder.scss index 555c70b8..3c259c90 100644 --- a/web/src/components/Header/Note/Placeholder.scss +++ b/web/src/components/Header/Note/Placeholder.scss @@ -23,7 +23,7 @@ @import '../../App/variables'; .wrapper { - background: $light-gray; + background: $lighter-gray; height: $header-height; width: 100%; position: relative; diff --git a/web/src/components/Header/Note/index.scss b/web/src/components/Header/Note/index.scss index 6a08b403..46537226 100644 --- a/web/src/components/Header/Note/index.scss +++ b/web/src/components/Header/Note/index.scss @@ -23,7 +23,7 @@ @import '../../App/variables'; .wrapper { - background: $light-gray; + background: $lighter-gray; padding: rem(12px) rem(20px); height: $header-height; z-index: 2; diff --git a/web/src/components/Header/SearchBar/SearchBar.scss b/web/src/components/Header/SearchBar/SearchBar.scss index 7c823ee4..c637866e 100644 --- a/web/src/components/Header/SearchBar/SearchBar.scss +++ b/web/src/components/Header/SearchBar/SearchBar.scss @@ -39,7 +39,8 @@ input.input { width: 100%; - @include breakpoint(lg) { + // Use custom breakpoint to optimize input width depending on the screen size + @media screen and (min-width: 1280px) { width: rem(480px); } } diff --git a/web/src/components/Home/Home.scss b/web/src/components/Home/Home.scss index a491646e..c75b33cc 100644 --- a/web/src/components/Home/Home.scss +++ b/web/src/components/Home/Home.scss @@ -19,5 +19,6 @@ @import '../App/responsive'; @import '../App/rem'; -.wrapper { +.toolbar { + text-align: right; } diff --git a/web/src/components/Home/NoteGroup/NoteItem.tsx b/web/src/components/Home/NoteGroup/NoteItem.tsx index 50e7f75e..6ce4b0e5 100644 --- a/web/src/components/Home/NoteGroup/NoteItem.tsx +++ b/web/src/components/Home/NoteGroup/NoteItem.tsx @@ -86,9 +86,9 @@ const NoteItem: React.FunctionComponent = ({ note, filters }) => {