diff --git a/commafeed-client/src/App.tsx b/commafeed-client/src/App.tsx
index 82314a73..f3c4017e 100644
--- a/commafeed-client/src/App.tsx
+++ b/commafeed-client/src/App.tsx
@@ -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() {
} />
} />
+
+ } />
+ } />
+
} />
} />
diff --git a/commafeed-client/src/app/client.ts b/commafeed-client/src/app/client.ts
index 3a13b429..d2405c6e 100644
--- a/commafeed-client/src/app/client.ts
+++ b/commafeed-client/src/app/client.ts
@@ -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("entry/tags"),
+ tag: (req: TagRequest) => axiosInstance.post("entry/tag", req),
},
feed: {
get: (id: string) => axiosInstance.get(`feed/get/${id}`),
diff --git a/commafeed-client/src/app/slices/entries.ts b/commafeed-client/src/app/slices/entries.ts
index 5345ae3e..95bebae8 100644
--- a/commafeed-client/src/app/slices/entries.ts
+++ b/commafeed-client/src/app/slices/entries.ts
@@ -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/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("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
+ })
+ })
},
})
diff --git a/commafeed-client/src/app/slices/redirect.ts b/commafeed-client/src/app/slices/redirect.ts
index 81b8bc6c..28029a76 100644
--- a/commafeed-client/src/app/slices/redirect.ts
+++ b/commafeed-client/src/app/slices/redirect.ts
@@ -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) =>
diff --git a/commafeed-client/src/app/slices/user.ts b/commafeed-client/src/app/slices/user.ts
index 4d69d55c..e317004a 100644
--- a/commafeed-client/src/app/slices/user.ts
+++ b/commafeed-client/src/app/slices/user.ts
@@ -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(
"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
diff --git a/commafeed-client/src/components/content/FeedEntryFooter.tsx b/commafeed-client/src/components/content/FeedEntryFooter.tsx
index dfd5c970..081ec38f 100644
--- a/commafeed-client/src/components/content/FeedEntryFooter.tsx
+++ b/commafeed-client/src/components/content/FeedEntryFooter.tsx
@@ -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).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 (
@@ -41,7 +60,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
/>
{showSharingButtons && (
-
+
} label={t`Share`} />
@@ -51,6 +70,25 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
)}
+ {tags && (
+
+
+ } label={t`Tags`} />
+
+
+ t`Create tag: ${query}`}
+ value={props.entry.tags}
+ onChange={onTagsChange}
+ />
+
+
+ )}
+
} label={t`Open link`} />
diff --git a/commafeed-client/src/components/sidebar/Tree.tsx b/commafeed-client/src/components/sidebar/Tree.tsx
index 5086c2ed..6765c59a 100644
--- a/commafeed-client/src/components/sidebar/Tree.tsx
+++ b/commafeed-client/src/components/sidebar/Tree.tsx
@@ -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 =
-const starredIcon =
-const expandedIcon =
-const collapsedIcon =
+const allIcon =
+const starredIcon =
+const tagIcon =
+const expandedIcon =
+const collapsedIcon =
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 = () => (
(
+
+ )
+
const recursiveCategoryNode = (category: Category, level = 0) => (
{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))}
)
diff --git a/commafeed-client/src/hooks/useAppLoading.ts b/commafeed-client/src/hooks/useAppLoading.ts
index 5c1d1dce..072a24f8 100644
--- a/commafeed-client/src/hooks/useAppLoading.ts
+++ b/commafeed-client/src/hooks/useAppLoading.ts
@@ -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)
diff --git a/commafeed-client/src/locales/en/messages.po b/commafeed-client/src/locales/en/messages.po
index 0a3d8272..3eaff486 100644
--- a/commafeed-client/src/locales/en/messages.po
+++ b/commafeed-client/src/locales/en/messages.po
@@ -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"
diff --git a/commafeed-client/src/locales/fr/messages.po b/commafeed-client/src/locales/fr/messages.po
index 856be309..6b4b8e96 100644
--- a/commafeed-client/src/locales/fr/messages.po
+++ b/commafeed-client/src/locales/fr/messages.po
@@ -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"
diff --git a/commafeed-client/src/pages/app/FeedEntriesPage.tsx b/commafeed-client/src/pages/app/FeedEntriesPage.tsx
index 4b9e2ee9..9e861c91 100644
--- a/commafeed-client/src/pages/app/FeedEntriesPage.tsx
+++ b/commafeed-client/src/pages/app/FeedEntriesPage.tsx
@@ -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(() => {
diff --git a/commafeed-client/src/pages/app/Layout.tsx b/commafeed-client/src/pages/app/Layout.tsx
index 5b20ce8d..f650f533 100644
--- a/commafeed-client/src/pages/app/Layout.tsx
+++ b/commafeed-client/src/pages/app/Layout.tsx
@@ -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)
diff --git a/commafeed-client/src/pages/app/TagDetailsPage.tsx b/commafeed-client/src/pages/app/TagDetailsPage.tsx
new file mode 100644
index 00000000..01522f89
--- /dev/null
+++ b/commafeed-client/src/pages/app/TagDetailsPage.tsx
@@ -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 (
+
+
+ {id}
+
+
+ {apiKey && (
+
+ Link
+
+ )}
+ {!apiKey && Generate an API key in your profile first.}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java
index d0a6049f..2353ada8 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java
@@ -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) {