diff --git a/pkg/server/app/digests.go b/pkg/server/app/digests.go index 241af343..d152a41b 100644 --- a/pkg/server/app/digests.go +++ b/pkg/server/app/digests.go @@ -22,6 +22,7 @@ import ( "fmt" "github.com/dnote/dnote/pkg/server/database" + "github.com/jinzhu/gorm" "github.com/pkg/errors" ) @@ -179,3 +180,27 @@ func (a *App) GetDigests(p GetDigestsParam) ([]database.Digest, error) { return ret, nil } + +// PreloadDigest preloads associations for the given digest. It returns a new digest. +func (a *App) PreloadDigest(d database.Digest) (database.Digest, error) { + var ret database.Digest + + conn := a.DB.Where("id = ?", d.ID). + Preload("Notes", func(db *gorm.DB) *gorm.DB { + return db.Order("notes.created_at DESC") + }). + Preload("Notes.Book"). + Preload("Notes.NoteReview", func(db *gorm.DB) *gorm.DB { + return db.Where("note_reviews.digest_id = ?", d.ID) + }). + Preload("Rule"). + Preload("Receipts", func(db *gorm.DB) *gorm.DB { + return db.Where("digest_receipts.user_id = ?", d.UserID) + }).First(&ret) + + if err := conn.Error; err != nil { + return ret, errors.Wrap(err, "preloading") + } + + return ret, nil +} diff --git a/pkg/server/app/users.go b/pkg/server/app/users.go index fa4bfadc..092d181c 100644 --- a/pkg/server/app/users.go +++ b/pkg/server/app/users.go @@ -51,7 +51,7 @@ func createEmailPreference(user database.User, tx *gorm.DB) error { func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error { r := database.RepetitionRule{ - Title: "Default repetition - all book", + Title: "Default repetition - all books", UserID: user.ID, Enabled: false, Hour: 20, diff --git a/pkg/server/handlers/digests.go b/pkg/server/handlers/digests.go index 575adc70..ea08e7af 100644 --- a/pkg/server/handlers/digests.go +++ b/pkg/server/handlers/digests.go @@ -29,23 +29,9 @@ import ( "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 { @@ -56,17 +42,18 @@ func (a *API) getDigest(w http.ResponseWriter, r *http.Request) { 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() { + d, err := a.App.GetUserDigestByUUID(user.ID, digestUUID) + if d == nil { RespondNotFound(w) return - } else if err := conn.Error; err != nil { + } + if err != nil { + HandleError(w, "finding digest", err, http.StatusInternalServerError) + return + } + + digest, err := a.App.PreloadDigest(*d) + if err != nil { HandleError(w, "finding digest", err, http.StatusInternalServerError) return } diff --git a/pkg/server/job/repetition/repetition.go b/pkg/server/job/repetition/repetition.go index bd4eb597..e938b148 100644 --- a/pkg/server/job/repetition/repetition.go +++ b/pkg/server/job/repetition/repetition.go @@ -158,7 +158,7 @@ func (c Context) notify(now time.Time, user database.User, digest database.Diges return errors.Wrap(err, "getting sender email") } - if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil { + if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindText, body); err != nil { return errors.Wrap(err, "queueing email") } diff --git a/web/src/components/Common/Menu/index.tsx b/web/src/components/Common/Menu/index.tsx index 2b27b88e..34f8fa38 100644 --- a/web/src/components/Common/Menu/index.tsx +++ b/web/src/components/Common/Menu/index.tsx @@ -75,6 +75,7 @@ interface MenuProps { triggerClassName?: string; contentClassName?: string; alignment: Alignment; + alignmentMd?: Alignment; direction: Direction; headerContent?: React.ReactNode; wrapperClassName?: string; @@ -93,6 +94,7 @@ const Menu: React.FunctionComponent = ({ triggerClassName, contentClassName, alignment, + alignmentMd, direction, headerContent, wrapperClassName, @@ -181,6 +183,7 @@ const Menu: React.FunctionComponent = ({ isOpen={isOpen} setIsOpen={setIsOpen} alignment={alignment} + alignmentMd={alignmentMd} direction={direction} renderContent={() => { return ( diff --git a/web/src/components/Common/PageToolbar/SelectMenu.tsx b/web/src/components/Common/PageToolbar/SelectMenu.tsx index 7a1aeb65..d2b03588 100644 --- a/web/src/components/Common/PageToolbar/SelectMenu.tsx +++ b/web/src/components/Common/PageToolbar/SelectMenu.tsx @@ -34,8 +34,8 @@ interface Props { headerText: string; menuId: string; alignment: Alignment; + alignmentMd?: Alignment; direction: Direction; - disabled?: boolean; wrapperClassName?: string; triggerClassName?: string; @@ -53,6 +53,7 @@ const SelectMenu: React.FunctionComponent = ({ triggerId, menuId, alignment, + alignmentMd, direction, wrapperClassName, triggerClassName @@ -82,6 +83,7 @@ const SelectMenu: React.FunctionComponent = ({ contentClassName={styles.content} wrapperClassName={wrapperClassName} alignment={alignment} + alignmentMd={alignmentMd} direction={direction} /> ); diff --git a/web/src/components/Common/Popover/Popover.module.scss b/web/src/components/Common/Popover/Popover.scss similarity index 100% rename from web/src/components/Common/Popover/Popover.module.scss rename to web/src/components/Common/Popover/Popover.scss diff --git a/web/src/components/Common/Popover/PopoverContent.scss b/web/src/components/Common/Popover/PopoverContent.scss index f6444ae1..d95672e2 100644 --- a/web/src/components/Common/Popover/PopoverContent.scss +++ b/web/src/components/Common/Popover/PopoverContent.scss @@ -37,19 +37,66 @@ } } +/* alignment */ .left-align { left: 0; + right: auto; + top: auto; + bottom: auto; } .right-align { right: 0; + left: auto; + top: auto; + bottom: auto; } .top-align { top: 0; + left: auto; + right: auto; + bottom: auto; } .bottom-align { bottom: 0; + left: auto; + right: auto; + top: auto; +} +// md +.left-align-md { + @include breakpoint(md) { + left: 0; + right: auto; + top: auto; + bottom: auto; + } +} +.right-align-md { + @include breakpoint(md) { + right: 0; + left: auto; + top: auto; + bottom: auto; + } +} +.top-align-md { + @include breakpoint(md) { + top: 0; + left: auto; + right: auto; + bottom: auto; + } +} +.bottom-align-md { + @include breakpoint(md) { + bottom: 0; + left: auto; + right: auto; + top: auto; + } } +/* direction */ .top-direction { bottom: calc(100% + 4px); } diff --git a/web/src/components/Common/Popover/PopoverContent.tsx b/web/src/components/Common/Popover/PopoverContent.tsx index 62f90328..841f70a7 100644 --- a/web/src/components/Common/Popover/PopoverContent.tsx +++ b/web/src/components/Common/Popover/PopoverContent.tsx @@ -32,6 +32,7 @@ interface Props { closeOnEscapeKeydown?: boolean; contentClassName?: string; alignment?: Alignment; + alignmentMd?: Alignment; triggerEl?: HTMLElement; wrapperEl?: any; hasBorder?: boolean; @@ -45,6 +46,7 @@ const PopoverContent: React.FunctionComponent = ({ wrapperEl, children, alignment, + alignmentMd, direction, hasBorder, closeOnOutsideClick, @@ -135,6 +137,12 @@ const PopoverContent: React.FunctionComponent = ({ [styles['right-align']]: alignment === 'right', [styles['top-align']]: alignment === 'top', [styles['bottom-align']]: alignment === 'bottom', + + [styles['left-align-md']]: alignmentMd === 'left', + [styles['right-align-md']]: alignmentMd === 'right', + [styles['top-align-md']]: alignmentMd === 'top', + [styles['bottom-align-md']]: alignmentMd === 'bottom', + // direction [styles['top-direction']]: direction === 'top', [styles['bottom-direction']]: direction === 'bottom', diff --git a/web/src/components/Common/Popover/index.tsx b/web/src/components/Common/Popover/index.tsx index 866822fc..7c3dbb56 100644 --- a/web/src/components/Common/Popover/index.tsx +++ b/web/src/components/Common/Popover/index.tsx @@ -20,7 +20,7 @@ import React, { useRef } from 'react'; import classnames from 'classnames'; import PopoverContent from './PopoverContent'; -import styles from './Popover.module.scss'; +import styles from './Popover.scss'; import { Alignment, Direction } from './types'; interface Props { @@ -29,6 +29,7 @@ interface Props { renderTrigger: (any) => React.ReactNode; renderContent: () => any; alignment: Alignment; + alignmentMd?: Alignment; direction: Direction; contentHasBorder?: boolean; hasArrow?: boolean; @@ -46,6 +47,7 @@ const Popover: React.FunctionComponent = ({ isOpen, setIsOpen, alignment, + alignmentMd, direction, renderTrigger, contentId, @@ -83,6 +85,7 @@ const Popover: React.FunctionComponent = ({ wrapperEl={wrapperRef.current} triggerEl={triggerRef.current} alignment={alignment} + alignmentMd={alignmentMd} direction={direction} contentId={contentId} hasBorder={contentHasBorder} diff --git a/web/src/components/Digest/Empty.scss b/web/src/components/Digest/Empty.scss new file mode 100644 index 00000000..88b7abe2 --- /dev/null +++ b/web/src/components/Digest/Empty.scss @@ -0,0 +1,29 @@ +/* Copyright (C) 2019, 2020 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 { + padding: rem(40px) rem(16px); + text-align: center; + color: $gray; +} diff --git a/web/src/components/Digest/Empty.tsx b/web/src/components/Digest/Empty.tsx new file mode 100644 index 00000000..42f2ef49 --- /dev/null +++ b/web/src/components/Digest/Empty.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { SearchParams, Status } from './types'; +import styles from './Empty.scss'; + +interface Props { + params: SearchParams; +} + +const Empty: React.FunctionComponent = ({ params }) => { + if (params.status === Status.Unreviewed) { + return ( +
+ You have completed reviewing this digest. +
+ ); + } + + return
No results matched your filters.
; +}; + +export default Empty; diff --git a/web/src/components/Digest/NoteList.tsx b/web/src/components/Digest/NoteList.tsx index e5aa3a66..790197f1 100644 --- a/web/src/components/Digest/NoteList.tsx +++ b/web/src/components/Digest/NoteList.tsx @@ -26,11 +26,14 @@ import { useDispatch } from '../../store'; import { setDigestNoteReviewed } from '../../store/digest'; import Placeholder from '../Common/Note/Placeholder'; import NoteItem from './NoteItem'; +import Empty from './Empty'; +import { SearchParams } from './types'; import styles from './Digest.scss'; interface Props { notes: DigestNoteData[]; digest: DigestData; + params: SearchParams; isFetched: boolean; isFetching: boolean; } @@ -38,6 +41,7 @@ interface Props { const NoteList: React.FunctionComponent = ({ isFetched, isFetching, + params, notes, digest }) => { @@ -62,6 +66,10 @@ const NoteList: React.FunctionComponent = ({ return null; } + if (notes.length === 0) { + return ; + } + return (
diff --git a/web/src/components/Digest/Toolbar/StatusMenu.tsx b/web/src/components/Digest/Toolbar/StatusMenu.tsx index 91afe957..2cc6f830 100644 --- a/web/src/components/Digest/Toolbar/StatusMenu.tsx +++ b/web/src/components/Digest/Toolbar/StatusMenu.tsx @@ -46,7 +46,7 @@ const StatusMenu: React.FunctionComponent = ({ const options = [ { - name: 'all', + name: 'unreviewed', value: ( = ({ }} ref={optRefs[0]} tabIndex={-1} - > - All - - ) - }, - { - name: 'unreviewed', - value: ( - { - setIsOpen(false); - }} - ref={optRefs[1]} - tabIndex={-1} > Unreviewed @@ -95,28 +75,48 @@ const StatusMenu: React.FunctionComponent = ({ onClick={() => { setIsOpen(false); }} - ref={optRefs[2]} + ref={optRefs[1]} tabIndex={-1} > Reviewed ) + }, + { + name: 'all', + value: ( + { + setIsOpen(false); + }} + ref={optRefs[2]} + tabIndex={-1} + > + All + + ) } ]; - const isActive = status === Status.Reviewed || status === Status.Unreviewed; + const isActive = status === Status.Reviewed || status === Status.All; let defaultCurrentOptionIdx: number; let statusText: string; if (status === Status.Reviewed) { - defaultCurrentOptionIdx = 2; - statusText = 'Reviewed'; - } else if (status === Status.Unreviewed) { defaultCurrentOptionIdx = 1; - statusText = 'Unreviewed'; + statusText = 'Reviewed'; + } else if (status === Status.All) { + defaultCurrentOptionIdx = 2; + statusText = 'All'; } else { defaultCurrentOptionIdx = 0; - statusText = 'All'; + statusText = 'Unreviewed'; } return ( @@ -134,8 +134,9 @@ const StatusMenu: React.FunctionComponent = ({ triggerClassName={classnames('button-no-padding', { [styles['active-menu-trigger']]: isActive })} - triggerText={` Status: ${statusText} `} - alignment="right" + triggerText={`Status: ${statusText}`} + alignment="left" + alignmentMd="right" direction="bottom" /> ); diff --git a/web/src/components/Digest/index.tsx b/web/src/components/Digest/index.tsx index 43a1fd1c..5e8afe69 100644 --- a/web/src/components/Digest/index.tsx +++ b/web/src/components/Digest/index.tsx @@ -79,24 +79,22 @@ function getNotes(notes: DigestNoteData[], p: SearchParams): DigestNoteData[] { }); } +const statusMap = { + [Status.All]: Status.All, + [Status.Reviewed]: Status.Reviewed, + [Status.Unreviewed]: Status.Unreviewed +}; + +const sortMap = { + [Sort.Newest]: Sort.Newest, + [Sort.Oldest]: Sort.Oldest +}; + 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; - } + const status = statusMap[searchObj.status] || Status.Unreviewed; + const sort = sortMap[searchObj.sort] || Sort.Newest; return { sort, @@ -153,6 +151,7 @@ const Digest: React.FunctionComponent = ({ location, match }) => {
. + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/variables'; +@import '../App/font'; +@import '../App/rem'; + +.flash { + margin-top: rem(20px); + + @include breakpoint(lg) { + margin-top: 0; + } +} diff --git a/web/src/components/Digests/Empty.scss b/web/src/components/Digests/Empty.scss new file mode 100644 index 00000000..93845513 --- /dev/null +++ b/web/src/components/Digests/Empty.scss @@ -0,0 +1,41 @@ +/* Copyright (C) 2019, 2020 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 { + padding: rem(40px) rem(16px); + text-align: center; + color: $gray; +} + +.support { + margin-top: rem(20px); +} + +.md-support { + display: none; + + @include breakpoint(md) { + display: block; + } +} diff --git a/web/src/components/Digests/Empty.tsx b/web/src/components/Digests/Empty.tsx new file mode 100644 index 00000000..f83c57fa --- /dev/null +++ b/web/src/components/Digests/Empty.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { getRepetitionsPath } from 'web/libs/paths'; +import styles from './Empty.scss'; + +interface Props {} + +const Empty: React.FunctionComponent = () => { + return ( +
+

No digests were found.

+ +

+ You could create repetition rules{' '} + first. +

+ +

+ Digests are automatically created based on your repetition rules. +

+
+ ); +}; + +export default Empty; diff --git a/web/src/components/Digests/List.tsx b/web/src/components/Digests/List.tsx index de0fd074..99426a24 100644 --- a/web/src/components/Digests/List.tsx +++ b/web/src/components/Digests/List.tsx @@ -22,6 +22,7 @@ import classnames from 'classnames'; import { DigestData } from 'jslib/operations/types'; import { getRange } from 'jslib/helpers/arr'; import Item from './Item'; +import Empty from './Empty'; import Placeholder from './Placeholder'; import styles from './List.scss'; @@ -49,6 +50,10 @@ const List: React.FunctionComponent = ({ return null; } + if (items.length === 0) { + return ; + } + return (
    {items.map(item => { diff --git a/web/src/components/Digests/index.tsx b/web/src/components/Digests/index.tsx index 5c3585b9..001e040d 100644 --- a/web/src/components/Digests/index.tsx +++ b/web/src/components/Digests/index.tsx @@ -17,10 +17,11 @@ */ import React, { useEffect } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import { Link, RouteComponentProps } from 'react-router-dom'; import Helmet from 'react-helmet'; import { usePrevious } from 'web/libs/hooks'; +import { getSubscriptionPath } from 'web/libs/paths'; import { parseSearchString } from 'jslib/helpers/url'; import { useDispatch, useSelector } from '../../store'; import { getDigests } from '../../store/digests'; @@ -28,6 +29,7 @@ import { Status } from './types'; import Flash from '../Common/Flash'; import List from './List'; import Toolbar from './Toolbar'; +import styles from './Digests.scss'; function useFetchDigests(params: { page: number; status: Status }) { const dispatch = useDispatch(); @@ -48,9 +50,10 @@ function useFetchDigests(params: { page: number; status: Status }) { interface Props extends RouteComponentProps {} const Digests: React.FunctionComponent = ({ location }) => { - const { digests } = useSelector(state => { + const { user, digests } = useSelector(state => { return { - digests: state.digests + digests: state.digests, + user: state.auth.user.data }; }); const { page, status } = parseSearchString(location.search); @@ -69,10 +72,21 @@ const Digests: React.FunctionComponent = ({ location }) => {

    Digests

    +
- +
+ Error getting digests: {digests.errorMessage} + + + Digests are not enabled on your plan.{' '} + Upgrade here. +
diff --git a/web/src/components/Repetition/Content.tsx b/web/src/components/Repetition/Content.tsx index 9e16428d..4eda8539 100644 --- a/web/src/components/Repetition/Content.tsx +++ b/web/src/components/Repetition/Content.tsx @@ -77,12 +77,16 @@ const Content: React.FunctionComponent = () => {
- + Repetitions are not enabled on your plan.{' '} Upgrade here. - + Please verify your email address in order to receive digests.{' '} Go to settings. diff --git a/web/src/components/Repetition/Repetition.scss b/web/src/components/Repetition/Repetition.scss index e6c67c45..b0198c58 100644 --- a/web/src/components/Repetition/Repetition.scss +++ b/web/src/components/Repetition/Repetition.scss @@ -30,3 +30,11 @@ .content { margin-top: rem(20px); } + +.flash { + margin-top: rem(20px); + + @include breakpoint(lg) { + margin-top: 0; + } +}