forked from Archives/Athou_commafeed
replace old client with new client from commafeed-ui repository
This commit is contained in:
107
commafeed-client/src/app/client.ts
Normal file
107
commafeed-client/src/app/client.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import axios from "axios"
|
||||
import {
|
||||
AddCategoryRequest,
|
||||
Category,
|
||||
CategoryModificationRequest,
|
||||
CollapseRequest,
|
||||
Entries,
|
||||
FeedInfo,
|
||||
FeedInfoRequest,
|
||||
FeedModificationRequest,
|
||||
GetEntriesPaginatedRequest,
|
||||
IDRequest,
|
||||
LoginRequest,
|
||||
MarkRequest,
|
||||
PasswordResetRequest,
|
||||
ProfileModificationRequest,
|
||||
RegistrationRequest,
|
||||
ServerInfo,
|
||||
Settings,
|
||||
SubscribeRequest,
|
||||
Subscription,
|
||||
UserModel,
|
||||
} from "./types"
|
||||
|
||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response.status === 401) window.location.hash = "/login"
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
export const client = {
|
||||
category: {
|
||||
getRoot: () => axiosInstance.get<Category>("category/get"),
|
||||
modify: (req: CategoryModificationRequest) => axiosInstance.post("category/modify", req),
|
||||
collapse: (req: CollapseRequest) => axiosInstance.post("category/collapse", req),
|
||||
getEntries: (req: GetEntriesPaginatedRequest) => axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||
markEntries: (req: MarkRequest) => axiosInstance.post("category/mark", req),
|
||||
add: (req: AddCategoryRequest) => axiosInstance.post("category/add", req),
|
||||
delete: (req: IDRequest) => axiosInstance.post("category/delete", req),
|
||||
},
|
||||
entry: {
|
||||
mark: (req: MarkRequest) => axiosInstance.post("entry/mark", req),
|
||||
},
|
||||
feed: {
|
||||
get: (id: string) => axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||
modify: (req: FeedModificationRequest) => axiosInstance.post("feed/modify", req),
|
||||
getEntries: (req: GetEntriesPaginatedRequest) => axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||
markEntries: (req: MarkRequest) => axiosInstance.post("feed/mark", req),
|
||||
fetchFeed: (req: FeedInfoRequest) => axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||
subscribe: (req: SubscribeRequest) => axiosInstance.post("feed/subscribe", req),
|
||||
unsubscribe: (req: IDRequest) => axiosInstance.post("feed/unsubscribe", req),
|
||||
importOpml: (req: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", req)
|
||||
return axiosInstance.post("feed/import", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
// TODO remove redirect from backend method then remove this
|
||||
validateStatus: () => true,
|
||||
})
|
||||
},
|
||||
},
|
||||
user: {
|
||||
login: (req: LoginRequest) => axiosInstance.post("user/login", req),
|
||||
register: (req: RegistrationRequest) => axiosInstance.post("user/register", req),
|
||||
passwordReset: (req: PasswordResetRequest) => axiosInstance.post("user/passwordReset", req),
|
||||
getSettings: () => axiosInstance.get<Settings>("user/settings"),
|
||||
saveSettings: (settings: Settings) => axiosInstance.post("user/settings", settings),
|
||||
getProfile: () => axiosInstance.get<UserModel>("user/profile"),
|
||||
saveProfile: (req: ProfileModificationRequest) => axiosInstance.post("user/profile", req),
|
||||
deleteProfile: () => axiosInstance.post("user/profile/deleteAccount"),
|
||||
},
|
||||
server: {
|
||||
getServerInfos: () => axiosInstance.get<ServerInfo>("server/get"),
|
||||
},
|
||||
admin: {
|
||||
getAllUsers: () => axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||
saveUser: (req: UserModel) => axiosInstance.post("admin/user/save", req),
|
||||
deleteUser: (req: IDRequest) => axiosInstance.post("admin/user/delete", req),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* transform an error object to an array of strings that can be displayed to the user
|
||||
* @param err an error object (e.g. from axios)
|
||||
* @returns an array of messages to show the user
|
||||
*/
|
||||
export const errorToStrings = (err: any) => {
|
||||
let strings: string[] = []
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
if (err.response) {
|
||||
const data = err.response.data as any
|
||||
if (typeof data === "string") strings.push(data)
|
||||
if (typeof data === "object" && data.message) strings.push(data.message)
|
||||
if (typeof data === "object" && data.errors) strings = [...strings, ...data.errors]
|
||||
}
|
||||
}
|
||||
|
||||
return strings
|
||||
}
|
||||
|
||||
export const errorsToStrings = (errors: any[]) => errors.map(errorToStrings).flat()
|
||||
15
commafeed-client/src/app/constants.ts
Normal file
15
commafeed-client/src/app/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { DEFAULT_THEME } from "@mantine/core"
|
||||
|
||||
export const Constants = {
|
||||
categoryIds: {
|
||||
all: "all",
|
||||
},
|
||||
layout: {
|
||||
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
|
||||
headerHeight: 60,
|
||||
sidebarWidth: 350,
|
||||
},
|
||||
dom: {
|
||||
mainScrollAreaId: "main-scroll-area-id",
|
||||
},
|
||||
}
|
||||
146
commafeed-client/src/app/slices/entries.test.ts
Normal file
146
commafeed-client/src/app/slices/entries.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/* eslint-disable import/first */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { DeepMockProxy, mockDeep, mockReset } from "vitest-mock-extended"
|
||||
|
||||
vi.doMock("app/client", () => ({ client: mockDeep() }))
|
||||
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { reducers } from "app/store"
|
||||
import { Entries, Entry } from "app/types"
|
||||
import { AxiosResponse } from "axios"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "./entries"
|
||||
|
||||
describe("entries", () => {
|
||||
const mockClient = client as DeepMockProxy<typeof client>
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockClient)
|
||||
})
|
||||
|
||||
it("loads entries", async () => {
|
||||
mockClient.feed.getEntries.mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "3" } as Entry],
|
||||
hasMore: false,
|
||||
name: "my-feed",
|
||||
errorCount: 3,
|
||||
feedLink: "https://mysite.com/feed",
|
||||
timestamp: 123,
|
||||
ignoredReadStatus: false,
|
||||
},
|
||||
} as AxiosResponse<Entries>)
|
||||
|
||||
const store = configureStore({ reducer: reducers })
|
||||
const promise = store.dispatch(
|
||||
loadEntries({
|
||||
sourceType: "feed",
|
||||
req: {
|
||||
id: "feed-id",
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().entries.source.type).toBe("feed")
|
||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||
expect(store.getState().entries.entries).toStrictEqual([])
|
||||
expect(store.getState().entries.hasMore).toBe(true)
|
||||
expect(store.getState().entries.sourceLabel).toBe("")
|
||||
expect(store.getState().entries.sourceWebsiteUrl).toBe("")
|
||||
expect(store.getState().entries.timestamp).toBeUndefined()
|
||||
|
||||
await promise
|
||||
expect(store.getState().entries.source.type).toBe("feed")
|
||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
|
||||
expect(store.getState().entries.hasMore).toBe(false)
|
||||
expect(store.getState().entries.sourceLabel).toBe("my-feed")
|
||||
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
|
||||
expect(store.getState().entries.timestamp).toBe(123)
|
||||
})
|
||||
|
||||
it("loads more entries", async () => {
|
||||
mockClient.category.getEntries.mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "4" } as Entry],
|
||||
hasMore: false,
|
||||
name: "my-feed",
|
||||
errorCount: 3,
|
||||
feedLink: "https://mysite.com/feed",
|
||||
timestamp: 123,
|
||||
ignoredReadStatus: false,
|
||||
},
|
||||
} as AxiosResponse<Entries>)
|
||||
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "category-id",
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [{ id: "3" } as Entry],
|
||||
hasMore: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const promise = store.dispatch(loadMoreEntries())
|
||||
|
||||
await promise
|
||||
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
|
||||
expect(store.getState().entries.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it("marks an entry as read", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "category-id",
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
||||
hasMore: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
|
||||
expect(store.getState().entries.entries).toStrictEqual([
|
||||
{ id: "3", read: true },
|
||||
{ id: "4", read: false },
|
||||
])
|
||||
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||
})
|
||||
|
||||
it("marks all entries as read", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "category-id",
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
||||
hasMore: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
|
||||
expect(store.getState().entries.entries).toStrictEqual([
|
||||
{ id: "3", read: true },
|
||||
{ id: "4", read: true },
|
||||
])
|
||||
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
|
||||
})
|
||||
})
|
||||
205
commafeed-client/src/app/slices/entries.ts
Normal file
205
commafeed-client/src/app/slices/entries.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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, GetEntriesRequest, MarkRequest } from "app/types"
|
||||
|
||||
export type EntrySourceType = "category" | "feed"
|
||||
export type EntrySource = { type: EntrySourceType; id: string }
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
|
||||
interface EntriesState {
|
||||
/** selected source */
|
||||
source: EntrySource
|
||||
sourceLabel: string
|
||||
sourceWebsiteUrl: string
|
||||
entries: ExpendableEntry[]
|
||||
/** stores when the first batch of entries were retrieved
|
||||
*
|
||||
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
|
||||
*/
|
||||
timestamp?: number
|
||||
selectedEntryId?: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
const initialState: EntriesState = {
|
||||
source: {
|
||||
type: "category",
|
||||
id: Constants.categoryIds.all,
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [],
|
||||
hasMore: true,
|
||||
}
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) => (sourceType === "category" ? client.category.getEntries : client.feed.getEntries)
|
||||
export const loadEntries = createAsyncThunk<Entries, { req: GetEntriesRequest; sourceType: EntrySourceType }, { state: RootState }>(
|
||||
"entries/load",
|
||||
async arg => {
|
||||
const endpoint = getEndpoint(arg.sourceType)
|
||||
const result = await endpoint({
|
||||
...arg.req,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
})
|
||||
return result.data
|
||||
}
|
||||
)
|
||||
export const loadMoreEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/loadMore", async (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const offset =
|
||||
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
|
||||
const endpoint = getEndpoint(state.entries.source.type)
|
||||
const result = await endpoint({
|
||||
id: state.entries.source.id,
|
||||
readType: state.user.settings?.readingMode,
|
||||
order: state.user.settings?.readingOrder,
|
||||
offset,
|
||||
limit: 50,
|
||||
})
|
||||
return result.data
|
||||
})
|
||||
export const reloadEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/reload", async (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const endpoint = getEndpoint(state.entries.source.type)
|
||||
const result = await endpoint({
|
||||
id: state.entries.source.id,
|
||||
readType: state.user.settings?.readingMode,
|
||||
order: state.user.settings?.readingOrder,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
})
|
||||
return result.data
|
||||
})
|
||||
export const markEntry = createAsyncThunk(
|
||||
"entries/entry/mark",
|
||||
(arg: { entry: Entry; read: boolean }) => {
|
||||
client.entry.mark({
|
||||
id: arg.entry.id,
|
||||
read: arg.read,
|
||||
})
|
||||
},
|
||||
{
|
||||
condition: arg => arg.entry.read !== arg.read,
|
||||
}
|
||||
)
|
||||
export const markAllEntries = createAsyncThunk("entries/entry/markAll", (arg: { sourceType: EntrySourceType; req: MarkRequest }) => {
|
||||
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
|
||||
endpoint(arg.req)
|
||||
})
|
||||
export const selectEntry = createAsyncThunk<void, Entry, { state: RootState }>("entries/entry/select", (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const entry = state.entries.entries.find(e => e.id === arg.id)
|
||||
if (!entry) return
|
||||
|
||||
// only mark entry as read if we're expanding
|
||||
if (!entry.expanded) {
|
||||
thunkApi.dispatch(markEntry({ entry, read: true }))
|
||||
}
|
||||
|
||||
// set entry as selected
|
||||
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
|
||||
|
||||
// collapse or expand entry if needed
|
||||
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
|
||||
if (entry === previouslySelectedEntry) {
|
||||
// selecting an entry already selected toggles expanded status
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: !entry.expanded }))
|
||||
} else {
|
||||
if (previouslySelectedEntry) {
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false }))
|
||||
}
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: true }))
|
||||
}
|
||||
})
|
||||
export const selectPreviousEntry = createAsyncThunk<void, void, { state: RootState }>("entries/entry/selectPrevious", (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
|
||||
if (previousIndex >= 0) {
|
||||
thunkApi.dispatch(selectEntry(entries[previousIndex]))
|
||||
}
|
||||
})
|
||||
export const selectNextEntry = createAsyncThunk<void, void, { state: RootState }>("entries/entry/selectNext", (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||
if (nextIndex < entries.length) {
|
||||
thunkApi.dispatch(selectEntry(entries[nextIndex]))
|
||||
}
|
||||
})
|
||||
|
||||
export const entriesSlice = createSlice({
|
||||
name: "entries",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
|
||||
state.selectedEntryId = action.payload.id
|
||||
},
|
||||
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.payload.entry.id)
|
||||
.forEach(e => {
|
||||
e.expanded = action.payload.expanded
|
||||
})
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.meta.arg.entry.id)
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, state => {
|
||||
state.entries.forEach(e => {
|
||||
e.read = true
|
||||
})
|
||||
})
|
||||
builder.addCase(loadEntries.pending, (state, action) => {
|
||||
state.source = {
|
||||
type: action.meta.arg.sourceType,
|
||||
id: action.meta.arg.req.id,
|
||||
}
|
||||
state.entries = []
|
||||
state.timestamp = undefined
|
||||
state.sourceLabel = ""
|
||||
state.sourceWebsiteUrl = ""
|
||||
state.hasMore = true
|
||||
state.selectedEntryId = undefined
|
||||
})
|
||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||
state.entries = action.payload.entries
|
||||
state.timestamp = action.payload.timestamp
|
||||
state.sourceLabel = action.payload.name
|
||||
state.sourceWebsiteUrl = action.payload.feedLink
|
||||
state.hasMore = action.payload.hasMore
|
||||
})
|
||||
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
||||
// remove already existing entries
|
||||
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
||||
state.entries = [...state.entries, ...entriesToAdd]
|
||||
state.hasMore = action.payload.hasMore
|
||||
})
|
||||
builder.addCase(reloadEntries.pending, state => {
|
||||
state.entries = []
|
||||
state.timestamp = undefined
|
||||
state.sourceLabel = ""
|
||||
state.sourceWebsiteUrl = ""
|
||||
state.hasMore = true
|
||||
state.selectedEntryId = undefined
|
||||
})
|
||||
builder.addCase(reloadEntries.fulfilled, (state, action) => {
|
||||
state.entries = action.payload.entries
|
||||
state.timestamp = action.payload.timestamp
|
||||
state.sourceLabel = action.payload.name
|
||||
state.sourceWebsiteUrl = action.payload.feedLink
|
||||
state.hasMore = action.payload.hasMore
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default entriesSlice.reducer
|
||||
10
commafeed-client/src/app/slices/redirect.test.ts
Normal file
10
commafeed-client/src/app/slices/redirect.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { store } from "app/store"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { redirectToCategory } from "./redirect"
|
||||
|
||||
describe("redirects", () => {
|
||||
it("redirects to category", async () => {
|
||||
await store.dispatch(redirectToCategory("1"))
|
||||
expect(store.getState().redirect.to).toBe("/app/category/1")
|
||||
})
|
||||
})
|
||||
52
commafeed-client/src/app/slices/redirect.ts
Normal file
52
commafeed-client/src/app/slices/redirect.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
import { Constants } from "app/constants"
|
||||
import { RootState } from "app/store"
|
||||
|
||||
interface RedirectState {
|
||||
to?: string
|
||||
}
|
||||
|
||||
const initialState: RedirectState = {}
|
||||
|
||||
export const redirectToLogin = createAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
||||
export const redirectToRegistration = createAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||
export const redirectToPasswordRecovery = createAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
||||
)
|
||||
export const redirectToSelectedSource = createAsyncThunk<void, void, { state: RootState }>("redirect/selectedSource", (_, thunkApi) => {
|
||||
const { source } = thunkApi.getState().entries
|
||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||
})
|
||||
export const redirectToCategory = createAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||
)
|
||||
export const redirectToRootCategory = createAsyncThunk("redirect/category/root", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectToCategory(Constants.categoryIds.all))
|
||||
)
|
||||
export const redirectToCategoryDetails = createAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||
)
|
||||
export const redirectToFeed = createAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||
)
|
||||
export const redirectToFeedDetails = createAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${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) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||
)
|
||||
|
||||
export const redirectSlice = createSlice({
|
||||
name: "redirect",
|
||||
initialState,
|
||||
reducers: {
|
||||
redirectTo: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.to = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { redirectTo } = redirectSlice.actions
|
||||
export default redirectSlice.reducer
|
||||
23
commafeed-client/src/app/slices/server.ts
Normal file
23
commafeed-client/src/app/slices/server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { ServerInfo } from "app/types"
|
||||
|
||||
interface ServerState {
|
||||
serverInfos?: ServerInfo
|
||||
}
|
||||
|
||||
const initialState: ServerState = {}
|
||||
|
||||
export const reloadServerInfos = createAsyncThunk("server/infos", () => client.server.getServerInfos().then(r => r.data))
|
||||
export const serverSlice = createSlice({
|
||||
name: "server",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
|
||||
state.serverInfos = action.payload
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default serverSlice.reducer
|
||||
77
commafeed-client/src/app/slices/tree.ts
Normal file
77
commafeed-client/src/app/slices/tree.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { Category, CollapseRequest } from "app/types"
|
||||
import { visitCategoryTree } from "app/utils"
|
||||
import { markAllEntries, markEntry } from "./entries"
|
||||
import { redirectTo } from "./redirect"
|
||||
|
||||
interface TreeState {
|
||||
rootCategory?: Category
|
||||
mobileMenuOpen: boolean
|
||||
}
|
||||
|
||||
const initialState: TreeState = {
|
||||
mobileMenuOpen: false,
|
||||
}
|
||||
|
||||
export const reloadTree = createAsyncThunk("tree/reload", () => client.category.getRoot().then(r => r.data))
|
||||
export const collapseTreeCategory = createAsyncThunk("tree/category/collapse", async (req: CollapseRequest) =>
|
||||
client.category.collapse(req)
|
||||
)
|
||||
|
||||
export const treeSlice = createSlice({
|
||||
name: "tree",
|
||||
initialState,
|
||||
reducers: {
|
||||
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.mobileMenuOpen = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||
state.rootCategory = action.payload
|
||||
})
|
||||
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
||||
if (!state.rootCategory) return
|
||||
visitCategoryTree(state.rootCategory, c => {
|
||||
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
|
||||
})
|
||||
})
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
if (!state.rootCategory) return
|
||||
visitCategoryTree(state.rootCategory, c =>
|
||||
c.feeds
|
||||
.filter(f => f.id === +action.meta.arg.entry.feedId)
|
||||
.forEach(f => {
|
||||
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
|
||||
})
|
||||
)
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
if (!state.rootCategory) return
|
||||
const { sourceType } = action.meta.arg
|
||||
const sourceId = action.meta.arg.req.id
|
||||
visitCategoryTree(state.rootCategory, c => {
|
||||
if (sourceType === "category" && c.id === sourceId) {
|
||||
visitCategoryTree(c, c2 =>
|
||||
c2.feeds.forEach(f => {
|
||||
f.unread = 0
|
||||
})
|
||||
)
|
||||
} else if (sourceType === "feed") {
|
||||
c.feeds
|
||||
.filter(f => f.id === +sourceId)
|
||||
.forEach(f => {
|
||||
f.unread = 0
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
builder.addCase(redirectTo, state => {
|
||||
state.mobileMenuOpen = false
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setMobileMenuOpen } = treeSlice.actions
|
||||
export default treeSlice.reducer
|
||||
80
commafeed-client/src/app/slices/user.ts
Normal file
80
commafeed-client/src/app/slices/user.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { RootState } from "app/store"
|
||||
import { ReadingMode, ReadingOrder, Settings, UserModel } from "app/types"
|
||||
|
||||
interface UserState {
|
||||
settings?: Settings
|
||||
profile?: UserModel
|
||||
}
|
||||
|
||||
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 changeReadingMode = createAsyncThunk<void, ReadingMode, { state: RootState }>(
|
||||
"settings/readingMode",
|
||||
(readingMode, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingMode })
|
||||
}
|
||||
)
|
||||
export const changeReadingOrder = createAsyncThunk<void, ReadingOrder, { state: RootState }>(
|
||||
"settings/readingOrder",
|
||||
(readingOrder, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingOrder })
|
||||
}
|
||||
)
|
||||
export const changeLanguage = createAsyncThunk<void, string, { state: RootState }>("settings/language", (language, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, language })
|
||||
})
|
||||
export const changeScrollSpeed = createAsyncThunk<void, boolean, { state: RootState }>("settings/scrollSpeed", (speed, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||
})
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||
state.settings = action.payload
|
||||
})
|
||||
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
||||
state.profile = action.payload
|
||||
})
|
||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingMode = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingOrder = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.language = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
||||
})
|
||||
builder.addMatcher(isAnyOf(changeLanguage.fulfilled, changeScrollSpeed.fulfilled), () => {
|
||||
showNotification({
|
||||
message: t`Settings saved.`,
|
||||
color: "green",
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default userSlice.reducer
|
||||
26
commafeed-client/src/app/store.ts
Normal file
26
commafeed-client/src/app/store.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import { setupListeners } from "@reduxjs/toolkit/query"
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||
import entriesReducer from "./slices/entries"
|
||||
import redirectReducer from "./slices/redirect"
|
||||
import serverReducer from "./slices/server"
|
||||
import treeReducer from "./slices/tree"
|
||||
import userReducer from "./slices/user"
|
||||
|
||||
export const reducers = {
|
||||
entries: entriesReducer,
|
||||
redirect: redirectReducer,
|
||||
tree: treeReducer,
|
||||
server: serverReducer,
|
||||
user: userReducer,
|
||||
}
|
||||
|
||||
export const store = configureStore({ reducer: reducers })
|
||||
|
||||
setupListeners(store.dispatch)
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||
261
commafeed-client/src/app/types.ts
Normal file
261
commafeed-client/src/app/types.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
export interface AddCategoryRequest {
|
||||
name: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface ApplicationSettings {
|
||||
publicUrl: string
|
||||
allowRegistrations: boolean
|
||||
createDemoAccount: boolean
|
||||
googleAnalyticsTrackingCode?: string
|
||||
googleAuthKey?: string
|
||||
backgroundThreads: number
|
||||
databaseUpdateThreads: number
|
||||
smtpHost?: string
|
||||
smtpPort?: number
|
||||
smtpTls?: boolean
|
||||
smtpUserName?: string
|
||||
smtpPassword?: string
|
||||
smtpFromAddress?: string
|
||||
graphiteEnabled?: boolean
|
||||
graphitePrefix?: string
|
||||
graphiteHost?: string
|
||||
graphitePort?: number
|
||||
graphiteInterval?: number
|
||||
heavyLoad: boolean
|
||||
pubsubhubbub: boolean
|
||||
imageProxyEnabled: boolean
|
||||
queryTimeout: number
|
||||
keepStatusDays: number
|
||||
maxFeedCapacity: number
|
||||
refreshIntervalMinutes: number
|
||||
cache: ApplicationSettingsCache
|
||||
announcement?: string
|
||||
userAgent?: string
|
||||
unreadThreshold?: Date
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
parentId?: string
|
||||
name: string
|
||||
children: Category[]
|
||||
feeds: Subscription[]
|
||||
expanded: boolean
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface CategoryModificationRequest {
|
||||
id: number
|
||||
name?: string
|
||||
parentId?: string
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface CollapseRequest {
|
||||
id: number
|
||||
collapse: boolean
|
||||
}
|
||||
|
||||
export interface Entries {
|
||||
name: string
|
||||
message?: string
|
||||
errorCount: number
|
||||
feedLink: string
|
||||
timestamp: number
|
||||
hasMore: boolean
|
||||
offset?: number
|
||||
limit?: number
|
||||
entries: Entry[]
|
||||
ignoredReadStatus: boolean
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
id: string
|
||||
guid: string
|
||||
title: string
|
||||
content: string
|
||||
categories?: string
|
||||
rtl: boolean
|
||||
author?: string
|
||||
enclosureUrl?: string
|
||||
enclosureType?: string
|
||||
mediaDescription?: string
|
||||
mediaThumbnailUrl?: string
|
||||
mediaThumbnailWidth?: number
|
||||
mediaThumbnailHeight?: number
|
||||
date: number
|
||||
insertedDate: number
|
||||
feedId: string
|
||||
feedName: string
|
||||
feedUrl: string
|
||||
feedLink: string
|
||||
iconUrl: string
|
||||
url: string
|
||||
read: boolean
|
||||
starred: boolean
|
||||
markable: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface FeedInfo {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface FeedInfoRequest {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface FeedModificationRequest {
|
||||
id: number
|
||||
name?: string
|
||||
categoryId?: string
|
||||
position?: number
|
||||
filter?: string
|
||||
}
|
||||
|
||||
export interface IDRequest {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MarkRequest {
|
||||
id: string
|
||||
read: boolean
|
||||
olderThan?: number
|
||||
keywords?: string
|
||||
excludedSubscriptions?: number[]
|
||||
}
|
||||
|
||||
export interface MultipleMarkRequest {
|
||||
requests: MarkRequest[]
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ProfileModificationRequest {
|
||||
currentPassword: string
|
||||
email: string
|
||||
newPassword?: string
|
||||
newApiKey?: boolean
|
||||
}
|
||||
|
||||
export interface RegistrationRequest {
|
||||
name: string
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
announcement?: string
|
||||
version: string
|
||||
gitCommit: string
|
||||
allowRegistrations: boolean
|
||||
googleAnalyticsCode?: string
|
||||
smtpEnabled: boolean
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
language: string
|
||||
readingMode: ReadingMode
|
||||
readingOrder: ReadingOrder
|
||||
viewMode: ViewMode
|
||||
showRead: boolean
|
||||
scrollMarks: boolean
|
||||
theme?: string
|
||||
customCss?: string
|
||||
scrollSpeed: number
|
||||
email: boolean
|
||||
gmail: boolean
|
||||
facebook: boolean
|
||||
twitter: boolean
|
||||
googleplus: boolean
|
||||
tumblr: boolean
|
||||
pocket: boolean
|
||||
instapaper: boolean
|
||||
buffer: boolean
|
||||
readability: boolean
|
||||
}
|
||||
|
||||
export interface StarRequest {
|
||||
id: string
|
||||
feedId: number
|
||||
starred: boolean
|
||||
}
|
||||
|
||||
export interface SubscribeRequest {
|
||||
url: string
|
||||
title: string
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
name: string
|
||||
message?: string
|
||||
errorCount: number
|
||||
lastRefresh?: number
|
||||
nextRefresh?: number
|
||||
feedUrl: string
|
||||
feedLink: string
|
||||
iconUrl: string
|
||||
unread: number
|
||||
categoryId?: string
|
||||
position?: number
|
||||
newestItemTime?: number
|
||||
filter?: string
|
||||
}
|
||||
|
||||
export interface TagRequest {
|
||||
entryId: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface GetEntriesRequest {
|
||||
id: string
|
||||
readType?: ReadingMode
|
||||
newerThan?: number
|
||||
order?: ReadingOrder
|
||||
keywords?: string
|
||||
onlyIds?: boolean
|
||||
excludedSubscriptionIds?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
feedId?: number
|
||||
unreadCount?: number
|
||||
newestItemTime?: number
|
||||
}
|
||||
|
||||
export interface UserModel {
|
||||
id: number
|
||||
name: string
|
||||
email?: string
|
||||
apiKey?: string
|
||||
password?: string
|
||||
enabled: boolean
|
||||
created: number
|
||||
lastLogin?: number
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export type ApplicationSettingsCache = "NOOP" | "REDIS"
|
||||
|
||||
export type ReadingMode = "all" | "unread"
|
||||
|
||||
export type ReadingOrder = "asc" | "desc"
|
||||
|
||||
export type ViewMode = "title" | "expanded"
|
||||
21
commafeed-client/src/app/utils.ts
Normal file
21
commafeed-client/src/app/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Category } from "./types"
|
||||
|
||||
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
|
||||
visitor(category)
|
||||
category.children.forEach(child => visitCategoryTree(child, visitor))
|
||||
}
|
||||
|
||||
export function flattenCategoryTree(category: Category): Category[] {
|
||||
const categories: Category[] = []
|
||||
visitCategoryTree(category, c => categories.push(c))
|
||||
return categories
|
||||
}
|
||||
|
||||
export function categoryUnreadCount(category?: Category): number {
|
||||
if (!category) return 0
|
||||
|
||||
return flattenCategoryTree(category)
|
||||
.flatMap(c => c.feeds)
|
||||
.map(f => f.unread)
|
||||
.reduce((total, current) => total + current, 0)
|
||||
}
|
||||
Reference in New Issue
Block a user