forked from Archives/Athou_commafeed
add support for tags
This commit is contained in:
@@ -22,6 +22,7 @@ import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
||||
import Layout from "pages/app/Layout"
|
||||
import { SettingsPage } from "pages/app/SettingsPage"
|
||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
||||
import { LoginPage } from "pages/auth/LoginPage"
|
||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
||||
@@ -80,6 +81,10 @@ function AppRoutes() {
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="feed" />} />
|
||||
<Route path=":id/details" element={<FeedDetailsPage />} />
|
||||
</Route>
|
||||
<Route path="tag">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="tag" />} />
|
||||
<Route path=":id/details" element={<TagDetailsPage />} />
|
||||
</Route>
|
||||
<Route path="add" element={<AddPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
StarRequest,
|
||||
SubscribeRequest,
|
||||
Subscription,
|
||||
TagRequest,
|
||||
UserModel,
|
||||
} from "./types"
|
||||
|
||||
@@ -48,6 +49,8 @@ export const client = {
|
||||
mark: (req: MarkRequest) => axiosInstance.post("entry/mark", req),
|
||||
markMultiple: (req: MultipleMarkRequest) => axiosInstance.post("entry/markMultiple", req),
|
||||
star: (req: StarRequest) => axiosInstance.post("entry/star", req),
|
||||
getTags: () => axiosInstance.get<string[]>("entry/tags"),
|
||||
tag: (req: TagRequest) => axiosInstance.post("entry/tag", req),
|
||||
},
|
||||
feed: {
|
||||
get: (id: string) => axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||
|
||||
@@ -2,13 +2,15 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { RootState } from "app/store"
|
||||
import { Entries, Entry, MarkRequest } from "app/types"
|
||||
import { Entries, Entry, MarkRequest, TagRequest } from "app/types"
|
||||
import { scrollToWithCallback } from "app/utils"
|
||||
import { flushSync } from "react-dom"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { reloadTree } from "./tree"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { reloadTags } from "./user"
|
||||
|
||||
export type EntrySourceType = "category" | "feed"
|
||||
export type EntrySourceType = "category" | "feed" | "tag"
|
||||
export type EntrySource = { type: EntrySourceType; id: string }
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
|
||||
@@ -40,16 +42,18 @@ const initialState: EntriesState = {
|
||||
scrollingToEntry: false,
|
||||
}
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) => (sourceType === "category" ? client.category.getEntries : client.feed.getEntries)
|
||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||
export const loadEntries = createAsyncThunk<Entries, EntrySource, { state: RootState }>("entries/load", async (source, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const endpoint = getEndpoint(source.type)
|
||||
const result = await endpoint({
|
||||
id: source.id,
|
||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||
order: state.user.settings?.readingOrder,
|
||||
readType: state.user.settings?.readingMode,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
tag: source.type === "tag" ? source.id : undefined,
|
||||
})
|
||||
return result.data
|
||||
})
|
||||
@@ -235,6 +239,10 @@ export const selectNextEntry = createAsyncThunk<
|
||||
)
|
||||
}
|
||||
})
|
||||
export const tagEntry = createAsyncThunk<void, TagRequest, { state: RootState }>("entries/entry/tag", async (arg, thunkApi) => {
|
||||
await client.entry.tag(arg)
|
||||
thunkApi.dispatch(reloadTags())
|
||||
})
|
||||
|
||||
export const entriesSlice = createSlice({
|
||||
name: "entries",
|
||||
@@ -305,6 +313,13 @@ export const entriesSlice = createSlice({
|
||||
state.entries = [...state.entries, ...entriesToAdd]
|
||||
state.hasMore = action.payload.hasMore
|
||||
})
|
||||
builder.addCase(tagEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => +e.id === action.meta.arg.entryId)
|
||||
.forEach(e => {
|
||||
e.tags = action.meta.arg.tags
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ export const redirectToFeed = createAsyncThunk("redirect/feed", (id: string | nu
|
||||
export const redirectToFeedDetails = createAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||
)
|
||||
export const redirectToTag = createAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||
export const redirectToTagDetails = createAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||
)
|
||||
export const redirectToAdd = createAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
||||
export const redirectToSettings = createAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
||||
export const redirectToAdminUsers = createAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||
|
||||
@@ -4,17 +4,20 @@ import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { RootState } from "app/store"
|
||||
import { ReadingMode, ReadingOrder, Settings, SharingSettings, UserModel, ViewMode } from "app/types"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { reloadEntries } from "./entries"
|
||||
|
||||
interface UserState {
|
||||
settings?: Settings
|
||||
profile?: UserModel
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const initialState: UserState = {}
|
||||
|
||||
export const reloadSettings = createAsyncThunk("settings/reload", () => client.user.getSettings().then(r => r.data))
|
||||
export const reloadProfile = createAsyncThunk("profile/reload", () => client.user.getProfile().then(r => r.data))
|
||||
export const reloadTags = createAsyncThunk("entries/tags", () => client.entry.getTags().then(r => r.data))
|
||||
export const changeReadingMode = createAsyncThunk<void, ReadingMode, { state: RootState }>(
|
||||
"settings/readingMode",
|
||||
(readingMode, thunkApi) => {
|
||||
@@ -85,6 +88,9 @@ export const userSlice = createSlice({
|
||||
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
||||
state.profile = action.payload
|
||||
})
|
||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||
state.tags = action.payload
|
||||
})
|
||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingMode = action.meta.arg
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { Checkbox, Group, Popover } from "@mantine/core"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/slices/entries"
|
||||
import { Checkbox, Group, MultiSelect, Popover } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/slices/entries"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Entry } from "app/types"
|
||||
import { ActionButton } from "components/ActionButtton"
|
||||
import { TbArrowBarToDown, TbExternalLink, TbShare, TbStar, TbStarOff } from "react-icons/tb"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TbArrowBarToDown, TbExternalLink, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
||||
import { ShareButtons } from "./ShareButtons"
|
||||
|
||||
interface FeedEntryFooterProps {
|
||||
@@ -12,13 +14,30 @@ interface FeedEntryFooterProps {
|
||||
}
|
||||
|
||||
export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
const [scrollPosition, setScrollPosition] = useState(0)
|
||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const showSharingButtons =
|
||||
sharingSettings && (Object.values(sharingSettings) as Array<typeof sharingSettings[keyof typeof sharingSettings]>).some(v => v)
|
||||
|
||||
const readStatusCheckboxClicked = () => dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))
|
||||
const onTagsChange = (values: string[]) =>
|
||||
dispatch(
|
||||
tagEntry({
|
||||
entryId: +props.entry.id,
|
||||
tags: values,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const scrollArea = document.getElementById(Constants.dom.mainScrollAreaId)
|
||||
|
||||
const listener = () => setScrollPosition(scrollArea ? scrollArea.scrollTop : 0)
|
||||
scrollArea?.addEventListener("scroll", listener)
|
||||
return () => scrollArea?.removeEventListener("scroll", listener)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Group position="apart">
|
||||
@@ -41,7 +60,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
/>
|
||||
|
||||
{showSharingButtons && (
|
||||
<Popover withArrow withinPortal shadow="md">
|
||||
<Popover withArrow withinPortal shadow="md" positionDependencies={[scrollPosition]}>
|
||||
<Popover.Target>
|
||||
<ActionButton icon={<TbShare size={18} />} label={t`Share`} />
|
||||
</Popover.Target>
|
||||
@@ -51,6 +70,25 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{tags && (
|
||||
<Popover withArrow withinPortal shadow="md" positionDependencies={[scrollPosition]}>
|
||||
<Popover.Target>
|
||||
<ActionButton icon={<TbTag size={18} />} label={t`Tags`} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MultiSelect
|
||||
data={tags}
|
||||
placeholder="Tags"
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={query => t`Create tag: ${query}`}
|
||||
value={props.entry.tags}
|
||||
onChange={onTagsChange}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<a href={props.entry.url} target="_blank" rel="noreferrer">
|
||||
<ActionButton icon={<TbExternalLink size={18} />} label={t`Open link`} />
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { Box, Stack } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectToCategory, redirectToCategoryDetails, redirectToFeed, redirectToFeedDetails } from "app/slices/redirect"
|
||||
import {
|
||||
redirectToCategory,
|
||||
redirectToCategoryDetails,
|
||||
redirectToFeed,
|
||||
redirectToFeedDetails,
|
||||
redirectToTag,
|
||||
redirectToTagDetails,
|
||||
} from "app/slices/redirect"
|
||||
import { collapseTreeCategory } from "app/slices/tree"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Category, Subscription } from "app/types"
|
||||
@@ -9,19 +16,21 @@ import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
|
||||
import { Loader } from "components/Loader"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
import React from "react"
|
||||
import { FaChevronDown, FaChevronRight, FaInbox, FaStar } from "react-icons/fa"
|
||||
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
||||
import { TreeNode } from "./TreeNode"
|
||||
import { TreeSearch } from "./TreeSearch"
|
||||
|
||||
const allIcon = <FaInbox size={14} />
|
||||
const starredIcon = <FaStar size={14} />
|
||||
const expandedIcon = <FaChevronDown size={14} />
|
||||
const collapsedIcon = <FaChevronRight size={14} />
|
||||
const allIcon = <TbInbox size={16} />
|
||||
const starredIcon = <TbStar size={16} />
|
||||
const tagIcon = <TbTag size={16} />
|
||||
const expandedIcon = <TbChevronDown size={16} />
|
||||
const collapsedIcon = <TbChevronRight size={16} />
|
||||
|
||||
const errorThreshold = 9
|
||||
export function Tree() {
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
const source = useAppSelector(state => state.entries.source)
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@@ -46,6 +55,10 @@ export function Tree() {
|
||||
})
|
||||
)
|
||||
}
|
||||
const tagClicked = (e: React.MouseEvent, id: string) => {
|
||||
if (e.detail === 2) dispatch(redirectToTagDetails(id))
|
||||
else dispatch(redirectToTag(id))
|
||||
}
|
||||
|
||||
const allCategoryNode = () => (
|
||||
<TreeNode
|
||||
@@ -114,6 +127,20 @@ export function Tree() {
|
||||
)
|
||||
}
|
||||
|
||||
const tagNode = (tag: string) => (
|
||||
<TreeNode
|
||||
id={tag}
|
||||
name={tag}
|
||||
icon={tagIcon}
|
||||
unread={0}
|
||||
selected={source.type === "tag" && source.id === tag}
|
||||
level={0}
|
||||
hasError={false}
|
||||
onClick={tagClicked}
|
||||
key={tag}
|
||||
/>
|
||||
)
|
||||
|
||||
const recursiveCategoryNode = (category: Category, level = 0) => (
|
||||
<React.Fragment key={`recursiveCategoryNode-${category.id}`}>
|
||||
{categoryNode(category, level)}
|
||||
@@ -134,6 +161,7 @@ export function Tree() {
|
||||
{starredCategoryNode()}
|
||||
{root.children.map(c => recursiveCategoryNode(c))}
|
||||
{root.feeds.map(f => feedNode(f))}
|
||||
{tags?.map(tag => tagNode(tag))}
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ export const useAppLoading = () => {
|
||||
const profile = useAppSelector(state => state.user.profile)
|
||||
const settings = useAppSelector(state => state.user.settings)
|
||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
@@ -24,6 +25,10 @@ export const useAppLoading = () => {
|
||||
label: t`Loading subscriptions...`,
|
||||
done: !!rootCategory,
|
||||
},
|
||||
{
|
||||
label: t`Loading tags...`,
|
||||
done: !!tags,
|
||||
},
|
||||
]
|
||||
|
||||
const loading = steps.some(s => !s.done)
|
||||
|
||||
@@ -181,6 +181,10 @@ msgstr "Confirm password"
|
||||
msgid "Cozy"
|
||||
msgstr "Cozy"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Create tag: {query}"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Current password"
|
||||
msgstr "Current password"
|
||||
@@ -379,6 +383,10 @@ msgstr "Loading settings..."
|
||||
msgid "Loading subscriptions..."
|
||||
msgstr "Loading subscriptions..."
|
||||
|
||||
#: src/hooks/useAppLoading.ts
|
||||
msgid "Loading tags..."
|
||||
msgstr "Loading tags..."
|
||||
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
msgid "Log in"
|
||||
|
||||
@@ -181,6 +181,10 @@ msgstr "Confirmer le mot de passe"
|
||||
msgid "Cozy"
|
||||
msgstr "Cozy"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Créer le tag: {query}"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Current password"
|
||||
msgstr "Mot de passe actuel"
|
||||
@@ -379,6 +383,10 @@ msgstr "Chargement des paramètres ..."
|
||||
msgid "Loading subscriptions..."
|
||||
msgstr "Chargement des abonnements ..."
|
||||
|
||||
#: src/hooks/useAppLoading.ts
|
||||
msgid "Loading tags..."
|
||||
msgstr "Chargement des tags ..."
|
||||
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
msgid "Log in"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActionIcon, Anchor, Box, Center, Divider, Group, Title, useMantineTheme
|
||||
import { useViewportSize } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
import { EntrySourceType, loadEntries } from "app/slices/entries"
|
||||
import { redirectToCategoryDetails, redirectToFeedDetails } from "app/slices/redirect"
|
||||
import { redirectToCategoryDetails, redirectToFeedDetails, redirectToTagDetails } from "app/slices/redirect"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { flattenCategoryTree } from "app/utils"
|
||||
import { FeedEntries } from "components/content/FeedEntries"
|
||||
@@ -40,7 +40,8 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) {
|
||||
|
||||
const titleClicked = () => {
|
||||
if (props.sourceType === "category") dispatch(redirectToCategoryDetails(id))
|
||||
else dispatch(redirectToFeedDetails(id))
|
||||
else if (props.sourceType === "feed") dispatch(redirectToFeedDetails(id))
|
||||
else if (props.sourceType === "tag") dispatch(redirectToTagDetails(id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useViewportSize } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectToAdd, redirectToRootCategory } from "app/slices/redirect"
|
||||
import { reloadTree, setMobileMenuOpen } from "app/slices/tree"
|
||||
import { reloadProfile, reloadSettings } from "app/slices/user"
|
||||
import { reloadProfile, reloadSettings, reloadTags } from "app/slices/user"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Logo } from "components/Logo"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
@@ -90,6 +90,7 @@ export default function Layout({ sidebar, header }: LayoutProps) {
|
||||
dispatch(reloadSettings())
|
||||
dispatch(reloadProfile())
|
||||
dispatch(reloadTree())
|
||||
dispatch(reloadTags())
|
||||
|
||||
// reload tree periodically
|
||||
const id = setInterval(() => dispatch(reloadTree()), 30000)
|
||||
|
||||
42
commafeed-client/src/pages/app/TagDetailsPage.tsx
Normal file
42
commafeed-client/src/pages/app/TagDetailsPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
|
||||
import { Anchor, Box, Button, Container, Group, Input, Stack, Title } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
export function TagDetailsPage() {
|
||||
const { id = Constants.categories.all.id } = useParams()
|
||||
|
||||
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title order={3}>{id}</Title>
|
||||
<Input.Wrapper label={t`Generated feed url`}>
|
||||
<Box>
|
||||
{apiKey && (
|
||||
<Anchor
|
||||
href={`rest/category/entriesAsFeed?id=${Constants.categories.all.id}&apiKey=${apiKey}&tag=${id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Trans>Link</Trans>
|
||||
</Anchor>
|
||||
)}
|
||||
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ public class EntryREST {
|
||||
@Path("/tag")
|
||||
@POST
|
||||
@UnitOfWork
|
||||
@ApiOperation(value = "Mark a feed entry", notes = "Mark a feed entry as read/unread")
|
||||
@ApiOperation(value = "Set feed entry tags")
|
||||
@Timed
|
||||
public Response tagEntry(@ApiParam(hidden = true) @SecurityCheck User user,
|
||||
@Valid @ApiParam(value = "Tag Request", required = true) TagRequest req) {
|
||||
|
||||
Reference in New Issue
Block a user