mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Start to update navigation to use new sections
This commit is contained in:
parent
371ed24371
commit
883d6619ea
15 changed files with 346 additions and 175 deletions
|
|
@ -23,7 +23,6 @@ import 'allotment/dist/style.css'
|
|||
import { LibrarySideBar } from './library/LibrarySideBar'
|
||||
|
||||
export type NavigationSection =
|
||||
| 'justread'
|
||||
| 'home'
|
||||
| 'library'
|
||||
| 'subscriptions'
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ export function LibraryItemsContainer(): JSX.Element {
|
|||
<Allotment.Pane minSize={200}>
|
||||
<LibraryContainer />
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane snap maxSize={230}>
|
||||
{/* <Allotment.Pane snap maxSize={230}>
|
||||
<LibrarySideBar />
|
||||
</Allotment.Pane>
|
||||
</Allotment.Pane> */}
|
||||
</Allotment>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { HomeIcon } from '../../elements/icons/HomeIcon'
|
|||
import { LibraryIcon } from '../../elements/icons/LibraryIcon'
|
||||
import { HighlightsIcon } from '../../elements/icons/HighlightsIcon'
|
||||
import { CoverImage } from '../../elements/CoverImage'
|
||||
import { Shortcut } from '../../../pages/settings/shortcuts'
|
||||
import { Shortcut } from './NavigationMenu'
|
||||
import { OutlinedLabelChip } from '../../elements/OutlinedLabelChip'
|
||||
import { NewsletterIcon } from '../../elements/icons/NewsletterIcon'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
|
|
|
|||
|
|
@ -241,8 +241,8 @@ const LibraryNav = (props: LibraryFilterMenuProps): JSX.Element => {
|
|||
<NavButton
|
||||
{...props}
|
||||
text="Home"
|
||||
section="justread"
|
||||
isSelected={props.section == 'justread'}
|
||||
section="home"
|
||||
isSelected={props.section == 'home'}
|
||||
icon={<HomeIcon color={theme.colors.thHomeIcon.toString()} />}
|
||||
/>
|
||||
<NavButton
|
||||
|
|
@ -385,7 +385,7 @@ async function setShortcuts(
|
|||
): Promise<Shortcut[]> {
|
||||
const url = new URL(path, fetchEndpoint)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -409,7 +409,7 @@ async function setShortcuts(
|
|||
async function resetShortcuts(path: string): Promise<Shortcut[]> {
|
||||
const url = new URL(path, fetchEndpoint)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -88,9 +88,10 @@ export function apiPoster(
|
|||
}
|
||||
|
||||
export function makePublicGqlFetcher(
|
||||
gql: string,
|
||||
variables?: unknown
|
||||
): (query: string) => Promise<unknown> {
|
||||
return (query: string) => gqlFetcher(query, variables, false)
|
||||
return (query: string) => gqlFetcher(gql, variables, false)
|
||||
}
|
||||
|
||||
// Partially apply gql variables to the request
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export function useGetLibraryItemsQuery({
|
|||
pageIndex === 0 ? undefined : previousResult.search.pageInfo.endCursor,
|
||||
]
|
||||
},
|
||||
(_query, _l, _s, _sq, cursor) => {
|
||||
(_query: string, _l: string, _s: string, _sq: string, cursor: string) => {
|
||||
return gqlFetcher(query, { ...variables, after: cursor }, true)
|
||||
},
|
||||
{ revalidateFirstPage: false }
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import useSWR from 'swr'
|
||||
import { makePublicGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers'
|
||||
import { Highlight } from '../fragments/highlightFragment'
|
||||
|
||||
type PublicArticleQueryInput = {
|
||||
username: string
|
||||
slug: string
|
||||
selectedHighlightId?: string
|
||||
}
|
||||
|
||||
export type PublicArticleQueryOutput = {
|
||||
publicArticle?: PublicArticleAttributes
|
||||
fetchError: unknown
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
}
|
||||
|
||||
type PublicArticleData = {
|
||||
sharedArticle: NestedPublicArticleData
|
||||
}
|
||||
|
||||
type NestedPublicArticleData = {
|
||||
article: PublicArticleAttributes
|
||||
}
|
||||
|
||||
export type PublicArticleAttributes = {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
url: string
|
||||
author?: string
|
||||
image?: string
|
||||
description?: string
|
||||
hasContent?: boolean
|
||||
highlights: Highlight[]
|
||||
}
|
||||
|
||||
export const PublicArticleGQLFragment = gql`
|
||||
fragment PublicArticle on Article {
|
||||
id
|
||||
title
|
||||
slug
|
||||
url
|
||||
author
|
||||
image
|
||||
description
|
||||
savedByViewer
|
||||
postedByViewer
|
||||
hasContent
|
||||
highlights {
|
||||
id
|
||||
shortId
|
||||
quote
|
||||
prefix
|
||||
suffix
|
||||
patch
|
||||
annotation
|
||||
sharedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
profile {
|
||||
id
|
||||
username
|
||||
pictureUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const query = gql`
|
||||
query GetPublicArticle(
|
||||
$username: String!
|
||||
$slug: String!
|
||||
$selectedHighlightId: String
|
||||
) {
|
||||
sharedArticle(
|
||||
username: $username
|
||||
slug: $slug
|
||||
selectedHighlightId: $selectedHighlightId
|
||||
) {
|
||||
... on SharedArticleSuccess {
|
||||
article {
|
||||
...PublicArticle
|
||||
}
|
||||
}
|
||||
|
||||
... on SharedArticleError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
${PublicArticleGQLFragment}
|
||||
`
|
||||
|
||||
export function useGetPublicArticleQuery({
|
||||
username,
|
||||
slug,
|
||||
selectedHighlightId,
|
||||
}: PublicArticleQueryInput): PublicArticleQueryOutput {
|
||||
const variables = {
|
||||
username,
|
||||
slug,
|
||||
selectedHighlightId,
|
||||
}
|
||||
|
||||
const { data, error, isValidating } = useSWR(
|
||||
// Only make request if username is defined
|
||||
!!username ? [query, username, slug, selectedHighlightId] : null,
|
||||
makePublicGqlFetcher(variables)
|
||||
)
|
||||
const publicArticle = (data as PublicArticleData)?.sharedArticle?.article
|
||||
|
||||
return {
|
||||
publicArticle,
|
||||
fetchError: error as unknown,
|
||||
isLoading: !error && !publicArticle,
|
||||
isValidating,
|
||||
}
|
||||
}
|
||||
|
||||
export async function publicArticleQuery(
|
||||
context: RequestContext,
|
||||
input: PublicArticleQueryInput
|
||||
): Promise<PublicArticleAttributes> {
|
||||
const result = (await ssrFetcher(context, query, input, false)) as PublicArticleData
|
||||
if (result.sharedArticle.article) {
|
||||
return result.sharedArticle.article
|
||||
}
|
||||
|
||||
return Promise.reject()
|
||||
}
|
||||
|
|
@ -24,7 +24,8 @@ export function useValidateUsernameQuery({
|
|||
// Don't fetch if username is empty
|
||||
const { data, error, isValidating } = useSWR(
|
||||
username ? [query, username] : null,
|
||||
makePublicGqlFetcher({ username })
|
||||
makePublicGqlFetcher(query, { username }),
|
||||
{}
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
|
|||
329
packages/web/pages/highlights/index.tsx
Normal file
329
packages/web/pages/highlights/index.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { NavigationLayout } from '../../components/templates/NavigationLayout'
|
||||
import { Box, HStack, VStack } from '../../components/elements/LayoutPrimitives'
|
||||
import { useFetchMore } from '../../lib/hooks/useFetchMoreScroll'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useGetHighlights } from '../../lib/networking/queries/useGetHighlights'
|
||||
import { Highlight } from '../../lib/networking/fragments/highlightFragment'
|
||||
import { NextRouter, useRouter } from 'next/router'
|
||||
import {
|
||||
UserBasicData,
|
||||
useGetViewerQuery,
|
||||
} from '../../lib/networking/queries/useGetViewerQuery'
|
||||
import { SetHighlightLabelsModalPresenter } from '../../components/templates/article/SetLabelsModalPresenter'
|
||||
import { TrashIcon } from '../../components/elements/icons/TrashIcon'
|
||||
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
|
||||
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
|
||||
import { deleteHighlightMutation } from '../../lib/networking/mutations/deleteHighlightMutation'
|
||||
import { LabelChip } from '../../components/elements/LabelChip'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles'
|
||||
import { HighlightHoverActions } from '../../components/patterns/HighlightHoverActions'
|
||||
import {
|
||||
autoUpdate,
|
||||
offset,
|
||||
size,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react'
|
||||
import { highlightColor } from '../../lib/themeUpdater'
|
||||
|
||||
import { HighlightViewNote } from '../../components/patterns/HighlightNotes'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function Highlights(): JSX.Element {
|
||||
const router = useRouter()
|
||||
const viewer = useGetViewerQuery()
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false)
|
||||
const [_, setShowAddLinkModal] = useState(false)
|
||||
|
||||
const { isLoading, setSize, size, data, mutate } = useGetHighlights({
|
||||
first: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
return data[data.length - 1].highlights.pageInfo.hasNextPage
|
||||
}, [data])
|
||||
|
||||
const handleFetchMore = useCallback(() => {
|
||||
if (isLoading || !hasMore) {
|
||||
return
|
||||
}
|
||||
setSize(size + 1)
|
||||
}, [isLoading, hasMore, setSize, size])
|
||||
|
||||
useFetchMore(handleFetchMore)
|
||||
|
||||
const highlights = useMemo(() => {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
return data.flatMap((res) => res.highlights.edges.map((edge) => edge.node))
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<NavigationLayout
|
||||
section="highlights"
|
||||
pageMetaDataProps={{
|
||||
title: 'Highlights',
|
||||
path: '/highlights',
|
||||
}}
|
||||
>
|
||||
<VStack
|
||||
css={{
|
||||
maxWidth: '70%',
|
||||
padding: '20px',
|
||||
margin: '30px 50px 0 0',
|
||||
}}
|
||||
>
|
||||
{highlights.map((highlight) => {
|
||||
return (
|
||||
viewer.viewerData?.me && (
|
||||
<HighlightCard
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
viewer={viewer.viewerData.me}
|
||||
router={router}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
</NavigationLayout>
|
||||
)
|
||||
}
|
||||
|
||||
type HighlightCardProps = {
|
||||
highlight: Highlight
|
||||
viewer: UserBasicData
|
||||
router: NextRouter
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
type HighlightAnnotationProps = {
|
||||
highlight: Highlight
|
||||
}
|
||||
|
||||
function HighlightAnnotation({
|
||||
highlight,
|
||||
}: HighlightAnnotationProps): JSX.Element {
|
||||
const [noteMode, setNoteMode] = useState<'edit' | 'preview'>('preview')
|
||||
const [annotation, setAnnotation] = useState(highlight.annotation)
|
||||
|
||||
return (
|
||||
<HighlightViewNote
|
||||
targetId={highlight.id}
|
||||
text={annotation}
|
||||
placeHolder="Add notes to this highlight..."
|
||||
highlight={highlight}
|
||||
mode={noteMode}
|
||||
setEditMode={setNoteMode}
|
||||
updateHighlight={(highlight) => {
|
||||
setAnnotation(highlight.annotation)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightCard(props: HighlightCardProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] =
|
||||
useState<undefined | string>(undefined)
|
||||
const [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const viewInReader = useCallback(
|
||||
(highlightId: string) => {
|
||||
const router = props.router
|
||||
const viewer = props.viewer
|
||||
const item = props.highlight.libraryItem
|
||||
|
||||
if (!router || !router.isReady || !viewer || !item) {
|
||||
showErrorToast('Error navigating to highlight')
|
||||
return
|
||||
}
|
||||
|
||||
router.push(
|
||||
{
|
||||
pathname: '/[username]/[slug]',
|
||||
query: {
|
||||
username: viewer.profile.username,
|
||||
slug: item.slug,
|
||||
},
|
||||
hash: highlightId,
|
||||
},
|
||||
`${viewer.profile.username}/${item.slug}#${highlightId}`,
|
||||
{
|
||||
scroll: false,
|
||||
}
|
||||
)
|
||||
},
|
||||
[props.highlight.libraryItem, props.viewer, props.router]
|
||||
)
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: -25,
|
||||
}),
|
||||
size(),
|
||||
],
|
||||
placement: 'top-end',
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
const hover = useHover(context)
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([hover])
|
||||
|
||||
return (
|
||||
<VStack
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
css={{
|
||||
width: '100%',
|
||||
fontFamily: '$inter',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
bg: '$thBackground2',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: '$thBackground3',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<HighlightHoverActions
|
||||
viewer={props.viewer}
|
||||
highlight={props.highlight}
|
||||
isHovered={isOpen ?? false}
|
||||
viewInReader={viewInReader}
|
||||
setLabelsTarget={setLabelsTarget}
|
||||
setShowConfirmDeleteHighlightId={setShowConfirmDeleteHighlightId}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
css={{
|
||||
width: '30px',
|
||||
height: '5px',
|
||||
backgroundColor: highlightColor(props.highlight.color),
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
css={{
|
||||
color: '$thText',
|
||||
fontSize: '11px',
|
||||
marginTop: '10px',
|
||||
fontWeight: 300,
|
||||
}}
|
||||
>
|
||||
{timeAgo(props.highlight.updatedAt)}
|
||||
</Box>
|
||||
{props.highlight.quote && (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{props.highlight.quote}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
<HighlightAnnotation highlight={props.highlight} />
|
||||
{props.highlight.labels && (
|
||||
<HStack
|
||||
css={{
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{props.highlight.labels.map((label) => {
|
||||
return (
|
||||
<LabelChip key={label.id} color={label.color} text={label.name} />
|
||||
)
|
||||
})}
|
||||
</HStack>
|
||||
)}
|
||||
<Box
|
||||
css={{
|
||||
color: '$thText',
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
fontWeight: 300,
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{props.highlight.libraryItem?.title}
|
||||
</Box>
|
||||
<Box
|
||||
css={{
|
||||
color: '$grayText',
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
fontWeight: 300,
|
||||
}}
|
||||
>
|
||||
{props.highlight.libraryItem?.author}
|
||||
</Box>
|
||||
{showConfirmDeleteHighlightId && (
|
||||
<ConfirmationModal
|
||||
message={'Are you sure you want to delete this highlight?'}
|
||||
onAccept={() => {
|
||||
;(async () => {
|
||||
const highlightId = showConfirmDeleteHighlightId
|
||||
const success = await deleteHighlightMutation(
|
||||
props.highlight.libraryItem?.id || '',
|
||||
showConfirmDeleteHighlightId
|
||||
)
|
||||
props.mutate()
|
||||
if (success) {
|
||||
showSuccessToast('Highlight deleted.', {
|
||||
position: 'bottom-right',
|
||||
})
|
||||
const event = new CustomEvent('deleteHighlightbyId', {
|
||||
detail: highlightId,
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
} else {
|
||||
showErrorToast('Error deleting highlight', {
|
||||
position: 'bottom-right',
|
||||
})
|
||||
}
|
||||
})()
|
||||
setShowConfirmDeleteHighlightId(undefined)
|
||||
}}
|
||||
onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)}
|
||||
icon={
|
||||
<TrashIcon
|
||||
size={40}
|
||||
color={theme.colors.grayTextContrast.toString()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{labelsTarget && (
|
||||
<SetHighlightLabelsModalPresenter
|
||||
highlight={labelsTarget}
|
||||
highlightId={labelsTarget.id}
|
||||
onUpdate={(highlight) => {
|
||||
// Don't actually need to do something here
|
||||
console.log('update highlight: ', highlight)
|
||||
}}
|
||||
onOpenChange={() => {
|
||||
props.mutate()
|
||||
setLabelsTarget(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { NavigationLayout } from '../../components/templates/NavigationLayout'
|
||||
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
|
||||
import { HomeFeedContainer } from '../../components/templates/homeFeed/HomeFeedContainer'
|
||||
import { VStack } from '../../components/elements/LayoutPrimitives'
|
||||
|
||||
export default function Highlights(): JSX.Element {
|
||||
return (
|
||||
<NavigationLayout
|
||||
section="highlights"
|
||||
pageMetaDataProps={{
|
||||
title: 'Highlights',
|
||||
path: '/highlights',
|
||||
}}
|
||||
>
|
||||
<VStack
|
||||
alignment="start"
|
||||
distribution="center"
|
||||
css={{
|
||||
px: '70px',
|
||||
backgroundColor: '$thLibraryBackground',
|
||||
'@lgDown': { px: '20px' },
|
||||
'@mdDown': { px: '10px' },
|
||||
}}
|
||||
>
|
||||
<div>Highlights will go here</div>
|
||||
</VStack>
|
||||
</NavigationLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export default function Home(): JSX.Element {
|
|||
useApplyLocalTheme()
|
||||
|
||||
return (
|
||||
<NavigationLayout section="justread">
|
||||
<NavigationLayout section="home">
|
||||
<VStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
|
|
@ -35,6 +35,7 @@ import { Button } from '../../components/elements/Button'
|
|||
import { styled } from '@stitches/react'
|
||||
import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment'
|
||||
import { escapeQuotes } from '../../utils/helper'
|
||||
import { Shortcut } from '../../components/templates/navMenu/NavigationMenu'
|
||||
type ListAction = 'RESET' | 'ADD_ITEM' | 'REMOVE_ITEM'
|
||||
|
||||
const SHORTCUTS_KEY = 'library-shortcuts'
|
||||
|
|
@ -318,6 +319,7 @@ const AvailableItems = (props: ListProps): JSX.Element => {
|
|||
id: search.id,
|
||||
name: search.name,
|
||||
type: 'label',
|
||||
section: 'library',
|
||||
filter: search.filter,
|
||||
}
|
||||
props.dispatchList({
|
||||
|
|
@ -364,6 +366,7 @@ const AvailableItems = (props: ListProps): JSX.Element => {
|
|||
id: label.id,
|
||||
type: 'label',
|
||||
label: label,
|
||||
section: 'library',
|
||||
name: label.name,
|
||||
filter: `label:\"${escapeQuotes(label.name)}\"`,
|
||||
}
|
||||
|
|
@ -408,6 +411,7 @@ const AvailableItems = (props: ListProps): JSX.Element => {
|
|||
onClick={(event) => {
|
||||
const item: Shortcut = {
|
||||
id: subscription.id,
|
||||
section: 'subscriptions',
|
||||
name: subscription.name,
|
||||
icon: subscription.icon,
|
||||
type:
|
||||
|
|
|
|||
Loading…
Reference in a new issue