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) {