mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-03 19:05:03 +00:00
use 120 max line length for both server and client code (was 100 and 140)
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 140
|
||||
"lineWidth": 120
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
|
||||
@@ -38,7 +38,8 @@ function Providers(
|
||||
children: React.ReactNode
|
||||
}>
|
||||
) {
|
||||
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||
const primaryColor =
|
||||
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MantineProvider
|
||||
@@ -87,7 +88,10 @@ function AppRoutes() {
|
||||
<Route path="register" element={<RegistrationPage />} />
|
||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||
<Route path="passwordReset" element={<PasswordResetPage />} />
|
||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
||||
<Route
|
||||
path="app"
|
||||
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
|
||||
>
|
||||
<Route path="category">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||
|
||||
@@ -61,7 +61,8 @@ export const client = {
|
||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||
await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||
@@ -76,7 +77,8 @@ export const client = {
|
||||
feed: {
|
||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||
await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||
@@ -107,7 +109,8 @@ export const client = {
|
||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||
passwordResetCallback: async (req: PasswordResetConfirmationRequest) => await axiosInstance.post("user/passwordResetCallback", req),
|
||||
passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
|
||||
await axiosInstance.post("user/passwordResetCallback", req),
|
||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||
@@ -156,9 +159,13 @@ function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFe
|
||||
}
|
||||
|
||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||
return (
|
||||
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||
)
|
||||
}
|
||||
|
||||
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||
return (
|
||||
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||
import {
|
||||
loadEntries,
|
||||
loadMoreEntries,
|
||||
markAllEntries,
|
||||
markEntry,
|
||||
markMultipleEntries,
|
||||
starEntry,
|
||||
tagEntry,
|
||||
} from "@/app/entries/thunks"
|
||||
import type { Entry } from "@/app/types"
|
||||
|
||||
export type EntrySourceType = "category" | "feed" | "tag"
|
||||
@@ -85,12 +93,16 @@ export const entriesSlice = createSlice({
|
||||
}
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
|
||||
for (const e of state.entries.filter(e =>
|
||||
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
|
||||
)) {
|
||||
e.read = true
|
||||
}
|
||||
})
|
||||
builder.addCase(starEntry.pending, (state, action) => {
|
||||
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
|
||||
for (const e of state.entries.filter(
|
||||
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
|
||||
)) {
|
||||
e.starred = action.meta.arg.starred
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
||||
const state = thunkApi.getState()
|
||||
const { source } = state.entries
|
||||
const offset =
|
||||
state.user.settings?.readingMode === "all" || state.entries.search || (source.type === "category" && source.id === "starred")
|
||||
state.user.settings?.readingMode === "all" ||
|
||||
state.entries.search ||
|
||||
(source.type === "category" && source.id === "starred")
|
||||
? state.entries.entries.length
|
||||
: state.entries.entries.filter(e => !e.read).length
|
||||
const endpoint = getEndpoint(state.entries.source.type)
|
||||
@@ -220,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
|
||||
|
||||
const entryIndex = state.entries.entries.indexOf(entry)
|
||||
const entriesToKeepOnTopWhenScrolling =
|
||||
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||
viewMode === "expanded"
|
||||
? 0
|
||||
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||
|
||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||
@@ -228,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
|
||||
if (entryElement && entryElementToScrollTo) {
|
||||
const scrollMode = state.user.settings?.scrollMode
|
||||
const entryEntirelyVisible =
|
||||
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
|
||||
Constants.layout.isTopVisible(entryElementToScrollTo) &&
|
||||
Constants.layout.isBottomVisible(entryElement)
|
||||
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||
const margin = viewMode === "detailed" ? 8 : 3
|
||||
@@ -242,7 +247,12 @@ export const selectEntry = createAppAsyncThunk(
|
||||
}
|
||||
)
|
||||
|
||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||
const scrollToEntry = (
|
||||
entryElement: HTMLElement,
|
||||
margin: number,
|
||||
scrollSpeed: number | undefined,
|
||||
onScrollEnded: () => void
|
||||
) => {
|
||||
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||
const offset = (header?.bottom ?? 0) + margin
|
||||
scrollToWithCallback({
|
||||
|
||||
@@ -2,11 +2,17 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { redirectTo } from "@/app/redirect/slice"
|
||||
|
||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/login"))
|
||||
)
|
||||
|
||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/register"))
|
||||
)
|
||||
|
||||
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
|
||||
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/setup"))
|
||||
)
|
||||
|
||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
||||
window.location.href = "api-documentation/"
|
||||
@@ -38,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||
)
|
||||
|
||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
|
||||
)
|
||||
|
||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||
)
|
||||
|
||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/add"))
|
||||
)
|
||||
|
||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/settings"))
|
||||
)
|
||||
|
||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||
@@ -56,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
|
||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||
)
|
||||
|
||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/donate"))
|
||||
)
|
||||
|
||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/about"))
|
||||
)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { client } from "@/app/client"
|
||||
|
||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
||||
export const reloadServerInfos = createAppAsyncThunk(
|
||||
"server/infos",
|
||||
async () => await client.server.getServerInfos().then(r => r.data)
|
||||
)
|
||||
|
||||
@@ -41,4 +41,5 @@ export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual)
|
||||
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
|
||||
useSelector(selector, shallowEqual)
|
||||
|
||||
@@ -6,7 +6,10 @@ import { incrementUnreadCount } from "@/app/tree/slice"
|
||||
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||
|
||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
||||
export const reloadTree = createAppAsyncThunk(
|
||||
"tree/reload",
|
||||
async () => await client.category.getRoot().then(r => r.data)
|
||||
)
|
||||
|
||||
export const collapseTreeCategory = createAppAsyncThunk(
|
||||
"tree/category/collapse",
|
||||
|
||||
@@ -2,14 +2,26 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { client } from "@/app/client"
|
||||
import type { Settings } from "@/app/types"
|
||||
|
||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
||||
export const reloadSettings = createAppAsyncThunk(
|
||||
"settings/reload",
|
||||
async () => await client.user.getSettings().then(r => r.data)
|
||||
)
|
||||
|
||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
||||
export const reloadProfile = createAppAsyncThunk(
|
||||
"profile/reload",
|
||||
async () => await client.user.getProfile().then(r => r.data)
|
||||
)
|
||||
|
||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
||||
export const reloadTags = createAppAsyncThunk(
|
||||
"entries/tags",
|
||||
async () => await client.entry.getTags().then(r => r.data)
|
||||
)
|
||||
|
||||
export const changeSettings = createAppAsyncThunk("settings/change", async (newSettings: Partial<Settings>, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
await client.user.saveSettings({ ...settings, ...newSettings })
|
||||
})
|
||||
export const changeSettings = createAppAsyncThunk(
|
||||
"settings/change",
|
||||
async (newSettings: Partial<Settings>, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
await client.user.saveSettings({ ...settings, ...newSettings })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,13 +43,27 @@ export function categoryHasNewEntries(category?: TreeCategory): boolean {
|
||||
.some(f => f.hasNewEntries)
|
||||
}
|
||||
|
||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
||||
export const calculatePlaceholderSize = ({
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
}: {
|
||||
width?: number
|
||||
height?: number
|
||||
maxWidth: number
|
||||
}) => {
|
||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||
return { width: placeholderWidth, height: placeholderHeight }
|
||||
}
|
||||
|
||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
||||
export const scrollToWithCallback = ({
|
||||
options,
|
||||
onScrollEnded,
|
||||
}: {
|
||||
options: ScrollToOptions
|
||||
onScrollEnded: () => void
|
||||
}) => {
|
||||
const offset = (options.top ?? 0).toFixed()
|
||||
|
||||
const onScroll = throttle(100, () => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { MessageDescriptor } from "@lingui/core"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { ActionIcon, type ActionIconVariant, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import {
|
||||
ActionIcon,
|
||||
type ActionIconVariant,
|
||||
Box,
|
||||
Button,
|
||||
type ButtonVariant,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core"
|
||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
|
||||
@@ -14,7 +14,9 @@ export function MarkAllAsReadConfirmationDialog() {
|
||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||
const search = useAppSelector(state => state.entries.search)
|
||||
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||
const markAllAsReadNavigateToNextUnread = useAppSelector(
|
||||
state => state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||
)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const onConfirm = async () => {
|
||||
@@ -33,7 +35,8 @@ export function MarkAllAsReadConfirmationDialog() {
|
||||
)
|
||||
|
||||
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected) await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -51,7 +54,8 @@ export function MarkAllAsReadConfirmationDialog() {
|
||||
)}
|
||||
{threshold > 0 && (
|
||||
<Trans>
|
||||
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
|
||||
Are you sure you want to mark entries older than {threshold} days of{" "}
|
||||
<Code>{sourceLabel}</Code> as read?
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -15,5 +15,12 @@ export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
|
||||
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
||||
}
|
||||
|
||||
return <Checkbox label={<Trans>Receive push notifications</Trans>} disabled={disabled} description={description} {...props} />
|
||||
return (
|
||||
<Checkbox
|
||||
label={<Trans>Receive push notifications</Trans>}
|
||||
disabled={disabled}
|
||||
description={description}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ export function UserEdit(props: Readonly<UserEditProps>) {
|
||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||
<Stack>
|
||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
||||
<PasswordInput
|
||||
label={<Trans>Password</Trans>}
|
||||
{...form.getInputProps("password")}
|
||||
required={!props.user}
|
||||
/>
|
||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||
|
||||
@@ -15,7 +15,9 @@ describe("Content component", () => {
|
||||
})
|
||||
|
||||
it("renders iframe tag when included in content", () => {
|
||||
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
|
||||
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
|
||||
wrapper: MantineProvider,
|
||||
})
|
||||
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Box, Mark } from "@mantine/core"
|
||||
import escapeStringRegexp from "escape-string-regexp"
|
||||
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
||||
import {
|
||||
ALLOWED_TAG_LIST,
|
||||
type ChildrenNode,
|
||||
Interweave,
|
||||
Matcher,
|
||||
type MatchResponse,
|
||||
type Node,
|
||||
type TransformCallback,
|
||||
} from "interweave"
|
||||
import React from "react"
|
||||
import styleToObject from "style-to-object"
|
||||
import { Constants } from "@/app/constants"
|
||||
|
||||
@@ -300,7 +300,9 @@ export function FeedEntries() {
|
||||
entry={entry}
|
||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||
selected={entry.id === selectedEntryId}
|
||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
||||
showSelectionIndicator={
|
||||
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
|
||||
}
|
||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||
onHeaderClick={event => headerClicked(entry, event)}
|
||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||
|
||||
@@ -34,67 +34,80 @@ const useStyles = tss
|
||||
maxWidth?: number
|
||||
fontSizePercentage: number
|
||||
}>()
|
||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
||||
let backgroundColor: string
|
||||
if (colorScheme === "dark") {
|
||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||
} else {
|
||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||
}
|
||||
.create(
|
||||
({
|
||||
theme,
|
||||
colorScheme,
|
||||
read,
|
||||
expanded,
|
||||
viewMode,
|
||||
rtl,
|
||||
showSelectionIndicator,
|
||||
maxWidth,
|
||||
fontSizePercentage,
|
||||
}) => {
|
||||
let backgroundColor: string
|
||||
if (colorScheme === "dark") {
|
||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||
} else {
|
||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||
}
|
||||
|
||||
let marginY = 10
|
||||
if (viewMode === "title") {
|
||||
marginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
marginY = 6
|
||||
}
|
||||
let marginY = 10
|
||||
if (viewMode === "title") {
|
||||
marginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
marginY = 6
|
||||
}
|
||||
|
||||
let mobileMarginY = 6
|
||||
if (viewMode === "title") {
|
||||
mobileMarginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
mobileMarginY = 4
|
||||
}
|
||||
let mobileMarginY = 6
|
||||
if (viewMode === "title") {
|
||||
mobileMarginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
mobileMarginY = 4
|
||||
}
|
||||
|
||||
let backgroundHoverColor = backgroundColor
|
||||
if (!expanded && !read) {
|
||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||
}
|
||||
let backgroundHoverColor = backgroundColor
|
||||
if (!expanded && !read) {
|
||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||
}
|
||||
|
||||
let paperBorderLeftColor = ""
|
||||
if (showSelectionIndicator) {
|
||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||
}
|
||||
let paperBorderLeftColor = ""
|
||||
if (showSelectionIndicator) {
|
||||
const borderLeftColor =
|
||||
colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||
}
|
||||
|
||||
return {
|
||||
paper: {
|
||||
backgroundColor,
|
||||
borderLeftColor: paperBorderLeftColor,
|
||||
marginTop: marginY,
|
||||
marginBottom: marginY,
|
||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||
marginTop: mobileMarginY,
|
||||
marginBottom: mobileMarginY,
|
||||
},
|
||||
"@media (hover: hover)": {
|
||||
"&:hover": {
|
||||
backgroundColor: backgroundHoverColor,
|
||||
return {
|
||||
paper: {
|
||||
backgroundColor,
|
||||
borderLeftColor: paperBorderLeftColor,
|
||||
marginTop: marginY,
|
||||
marginBottom: marginY,
|
||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||
marginTop: mobileMarginY,
|
||||
marginBottom: mobileMarginY,
|
||||
},
|
||||
"@media (hover: hover)": {
|
||||
"&:hover": {
|
||||
backgroundColor: backgroundHoverColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
headerLink: {
|
||||
fontSize: `${fontSizePercentage}%`,
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
},
|
||||
body: {
|
||||
fontSize: `${fontSizePercentage}%`,
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
maxWidth: maxWidth ?? "100%",
|
||||
},
|
||||
headerLink: {
|
||||
fontSize: `${fontSizePercentage}%`,
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
},
|
||||
body: {
|
||||
fontSize: `${fontSizePercentage}%`,
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
maxWidth: maxWidth ?? "100%",
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||
@@ -116,7 +129,9 @@ export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||
const showExternalLinkIcon =
|
||||
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
||||
const showStarIcon =
|
||||
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||
props.entry.markable &&
|
||||
starIconDisplayMode &&
|
||||
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: props.onSwipedLeft,
|
||||
|
||||
@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
|
||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||
|
||||
return (
|
||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
||||
<Menu
|
||||
id={Constants.dom.entryContextMenuId(props.entry)}
|
||||
theme={colorScheme}
|
||||
animation={false}
|
||||
className={classes.menu}
|
||||
>
|
||||
<Item
|
||||
onClick={() => {
|
||||
window.open(props.entry.url, "_blank", "noreferrer")
|
||||
@@ -63,13 +68,19 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
|
||||
|
||||
{props.entry.markable && (
|
||||
<>
|
||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||
<Item
|
||||
onClick={async () =>
|
||||
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
|
||||
}
|
||||
>
|
||||
<Group>
|
||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||
<Item
|
||||
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
|
||||
>
|
||||
<Group>
|
||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
||||
import {
|
||||
TbArrowBarToDown,
|
||||
TbExternalLink,
|
||||
TbMail,
|
||||
TbMailOpened,
|
||||
TbShare,
|
||||
TbStar,
|
||||
TbStarOff,
|
||||
TbTag,
|
||||
} from "react-icons/tb"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
|
||||
{tags && (
|
||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||
<Popover.Target>
|
||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
||||
<Indicator
|
||||
label={props.entry.tags.length}
|
||||
disabled={props.entry.tags.length === 0}
|
||||
inline
|
||||
size={16}
|
||||
>
|
||||
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||
</Indicator>
|
||||
</Popover.Target>
|
||||
|
||||
@@ -104,7 +104,9 @@ export function ShareButtons(
|
||||
}>
|
||||
) {
|
||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
|
||||
site => sharingSettings?.[site]
|
||||
)
|
||||
const url = encodeURIComponent(props.url)
|
||||
const desc = encodeURIComponent(props.description)
|
||||
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
||||
@@ -119,7 +121,9 @@ export function ShareButtons(
|
||||
{showNativeSection && (
|
||||
<SimpleGrid cols={4}>
|
||||
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
||||
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
|
||||
{nativeSharingAvailable && (
|
||||
<BrowserNativeShareButton url={props.url} description={props.description} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
|
||||
@@ -36,7 +36,12 @@ export function AddCategory() {
|
||||
|
||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||
<Stack>
|
||||
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
|
||||
<TextInput
|
||||
label={<Trans>Category</Trans>}
|
||||
placeholder={_(msg`Category`)}
|
||||
{...form.getInputProps("name")}
|
||||
required
|
||||
/>
|
||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||
<Group justify="center">
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
|
||||
@@ -44,8 +44,8 @@ export function ImportOpml() {
|
||||
placeholder={_(msg`OPML file`)}
|
||||
description={
|
||||
<Trans>
|
||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
||||
data from other feed reading services.
|
||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML
|
||||
file by exporting your data from other feed reading services.
|
||||
</Trans>
|
||||
}
|
||||
{...form.getInputProps("file")}
|
||||
|
||||
@@ -85,8 +85,8 @@ export function Subscribe() {
|
||||
placeholder="https://www.mysite.com/rss"
|
||||
description={
|
||||
<Trans>
|
||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
||||
will try to find the feed in the page.
|
||||
The URL for the feed you want to subscribe to. You can also use the website's url
|
||||
directly and CommaFeed will try to find the feed in the page.
|
||||
</Trans>
|
||||
}
|
||||
required
|
||||
@@ -101,8 +101,17 @@ export function Subscribe() {
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
||||
<TextInput
|
||||
label={<Trans>Feed name</Trans>}
|
||||
{...step1Form.getInputProps("title")}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<CategorySelect
|
||||
label={<Trans>Category</Trans>}
|
||||
{...step1Form.getInputProps("categoryId")}
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
@@ -117,7 +126,11 @@ export function Subscribe() {
|
||||
</Button>
|
||||
)}
|
||||
{activeStep === 1 && (
|
||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<TbRss size={16} />}
|
||||
loading={fetchFeed.loading || subscribe.loading}
|
||||
>
|
||||
<Trans>Subscribe</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,16 @@ const fields: Field[] = [
|
||||
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||
]
|
||||
|
||||
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"])
|
||||
const textOperators = new Set([
|
||||
"=",
|
||||
"!=",
|
||||
"contains",
|
||||
"beginsWith",
|
||||
"endsWith",
|
||||
"doesNotContain",
|
||||
"doesNotBeginWith",
|
||||
"doesNotEndWith",
|
||||
])
|
||||
|
||||
function toCelString(query: RuleGroupType): string {
|
||||
if (query.rules.length === 0) {
|
||||
|
||||
@@ -24,7 +24,10 @@ export function Star(
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
|
||||
<Tooltip
|
||||
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
openDelay={Constants.tooltip.delay}
|
||||
>
|
||||
<ActionIcon variant="transparent" onClick={onClick}>
|
||||
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
TbSortDescending,
|
||||
TbUser,
|
||||
} from "react-icons/tb"
|
||||
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks"
|
||||
import {
|
||||
markAllAsReadWithConfirmationIfRequired,
|
||||
reloadEntries,
|
||||
search,
|
||||
selectNextEntry,
|
||||
selectPreviousEntry,
|
||||
} from "@/app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { changeSettings } from "@/app/user/thunks"
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
@@ -143,7 +149,13 @@ export function Header() {
|
||||
onClick={toggleReadingMode}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
||||
icon={
|
||||
settings.readingOrder === "asc" ? (
|
||||
<TbSortAscending size={iconSize} />
|
||||
) : (
|
||||
<TbSortDescending size={iconSize} />
|
||||
)
|
||||
}
|
||||
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||
onClick={toggleReadingOrder}
|
||||
/>
|
||||
@@ -160,7 +172,11 @@ export function Header() {
|
||||
placeholder={_(msg`Search`)}
|
||||
{...searchForm.getInputProps("search")}
|
||||
leftSection={<TbSearch size={iconSize} />}
|
||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={async () => await (searchFromStore && dispatch(search("")))}
|
||||
/>
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -31,7 +31,13 @@ import {
|
||||
} from "react-icons/tb"
|
||||
import { throttle } from "throttle-debounce"
|
||||
import { client } from "@/app/client"
|
||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks"
|
||||
import {
|
||||
redirectToAbout,
|
||||
redirectToAdminUsers,
|
||||
redirectToDonate,
|
||||
redirectToMetrics,
|
||||
redirectToSettings,
|
||||
} from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { ViewMode } from "@/app/types"
|
||||
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||
@@ -124,7 +130,13 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened} preventPositionChangeWhenVisible={false}>
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
closeOnItemClick={false}
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
preventPositionChangeWhenVisible={false}
|
||||
>
|
||||
<Menu.Target>{props.control}</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||
@@ -164,7 +176,9 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||
}}
|
||||
>
|
||||
<Trans>Fetch all my feeds now</Trans>
|
||||
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
|
||||
{!forceRefreshEnabled && (
|
||||
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -83,7 +83,11 @@ export function CustomCodeSettings() {
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<TbDeviceFloppy size={16} />}
|
||||
loading={saveCustomCode.loading}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -111,7 +111,11 @@ export function DisplaySettings() {
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>}
|
||||
label={
|
||||
<Trans>
|
||||
Navigate to the next category/feed with unread entries when marking all entries as read
|
||||
</Trans>
|
||||
}
|
||||
checked={settings.markAllAsReadNavigateToNextUnread}
|
||||
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Input,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { openConfirmModal } from "@mantine/modals"
|
||||
import { useEffect } from "react"
|
||||
@@ -91,8 +103,9 @@ export function ProfileSettings() {
|
||||
label={<Trans>API key</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API
|
||||
and the Google Reader API. Use the form at the bottom of the page to generate a new API key
|
||||
This is your API key. It can be used for some read-only API operations and grants access
|
||||
to the Fever API and the Google Reader API. Use the form at the bottom of the page to
|
||||
generate a new API key
|
||||
</Trans>
|
||||
}
|
||||
readOnly
|
||||
@@ -103,7 +116,8 @@ export function ProfileSettings() {
|
||||
label={<Trans>OPML export</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
|
||||
Export your subscriptions and categories as an OPML file that can be imported in other
|
||||
feed reading services
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
@@ -118,8 +132,8 @@ export function ProfileSettings() {
|
||||
label={<Trans>Fever API</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
|
||||
Login with your username and your <u>API key</u>.
|
||||
CommaFeed is compatible with the Fever API. Use the following URL in your
|
||||
Fever-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
@@ -134,8 +148,8 @@ export function ProfileSettings() {
|
||||
label={<Trans>Google Reader API</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible
|
||||
mobile client. Login with your username and your <u>API key</u>.
|
||||
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google
|
||||
Reader-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
@@ -165,8 +179,14 @@ export function ProfileSettings() {
|
||||
description={<Trans>Changing password will generate a new API key</Trans>}
|
||||
{...form.getInputProps("newPassword")}
|
||||
/>
|
||||
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
|
||||
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
|
||||
<PasswordInput
|
||||
label={<Trans>Confirm password</Trans>}
|
||||
{...form.getInputProps("newPasswordConfirmation")}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Generate new API key</Trans>}
|
||||
{...form.getInputProps("newApiKey", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
|
||||
@@ -50,8 +50,8 @@ export function PushNotificationSettings() {
|
||||
label={<Trans>Push notification service</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Receive push notifications when new feed entries are discovered. Enable "Receive push notifications" in the
|
||||
settings of each feed for which you want to receive notifications.
|
||||
Receive push notifications when new feed entries are discovered. Enable "Receive push
|
||||
notifications" in the settings of each feed for which you want to receive notifications.
|
||||
</Trans>
|
||||
}
|
||||
data={[
|
||||
@@ -77,7 +77,12 @@ export function PushNotificationSettings() {
|
||||
required
|
||||
{...form.getInputProps("serverUrl")}
|
||||
/>
|
||||
<TextInput label={<Trans>Topic</Trans>} placeholder="commafeed" required {...form.getInputProps("topic")} />
|
||||
<TextInput
|
||||
label={<Trans>Topic</Trans>}
|
||||
placeholder="commafeed"
|
||||
required
|
||||
{...form.getInputProps("topic")}
|
||||
/>
|
||||
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -119,8 +119,11 @@ export function Tree() {
|
||||
const categoryNode = (category: Category, level = 0) => {
|
||||
if (!isCategoryDisplayed(category)) return null
|
||||
|
||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||
const hasError =
|
||||
!category.expanded &&
|
||||
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||
const hasWarning =
|
||||
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||
return (
|
||||
<TreeNode
|
||||
id={category.id}
|
||||
|
||||
@@ -80,7 +80,11 @@ export function TreeNode(props: Readonly<TreeNodeProps>) {
|
||||
data-type={props.type}
|
||||
data-unread-count={props.unread}
|
||||
>
|
||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
|
||||
<Box
|
||||
mr={6}
|
||||
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
|
||||
className="cf-treenode-icon"
|
||||
>
|
||||
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
||||
</Box>
|
||||
<Box className={classes.nodeText}>{props.name}</Box>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
Space,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { SiGithub, SiX } from "react-icons/si"
|
||||
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
|
||||
import { client } from "@/app/client"
|
||||
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "@/app/redirect/thunks"
|
||||
import {
|
||||
redirectToApiDocumentation,
|
||||
redirectToLogin,
|
||||
redirectToRegistration,
|
||||
redirectToRootCategory,
|
||||
} from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import welcomePageDark from "@/assets/welcome_page_dark.png"
|
||||
import welcomePageLight from "@/assets/welcome_page_light.png"
|
||||
|
||||
@@ -69,7 +69,11 @@ export function AdminUsersPage() {
|
||||
<Title order={3} mb="md">
|
||||
<Group>
|
||||
<Trans>Manage users</Trans>
|
||||
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
|
||||
<ActionIcon
|
||||
color={theme.primaryColor}
|
||||
variant="subtle"
|
||||
onClick={() => openUserEditModal(<Trans>Add user</Trans>)}
|
||||
>
|
||||
<TbPlus size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
@@ -19,8 +19,10 @@ const shownGauges: Record<string, string> = {
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active": "Feed Refresh Engine active push notifications workers",
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue": "Feed Refresh Engine queued push notifications workers",
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active":
|
||||
"Feed Refresh Engine active push notifications workers",
|
||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue":
|
||||
"Feed Refresh Engine queued push notifications workers",
|
||||
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
|
||||
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
|
||||
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
|
||||
|
||||
@@ -51,7 +51,12 @@ function NextUnreadBookmarklet() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
|
||||
<CategorySelect
|
||||
value={categoryId}
|
||||
onChange={c => c && setCategoryId(c)}
|
||||
withAll
|
||||
description={<Trans>Category</Trans>}
|
||||
/>
|
||||
<NativeSelect
|
||||
data={[
|
||||
{ value: "desc", label: _(msg`Newest first`) },
|
||||
@@ -73,7 +78,8 @@ function NextUnreadBookmarklet() {
|
||||
export function AboutPage() {
|
||||
const version = useAppSelector(state => state.server.serverInfos?.version)
|
||||
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
|
||||
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
|
||||
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } =
|
||||
useBrowserExtension()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return (
|
||||
@@ -100,7 +106,9 @@ export function AboutPage() {
|
||||
</Trans>
|
||||
</Box>
|
||||
<Box>
|
||||
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
|
||||
<Trans>
|
||||
If you encounter an issue, please report it on the issues page of the GitHub project.
|
||||
</Trans>
|
||||
</Box>
|
||||
</Section>
|
||||
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Input,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { openConfirmModal } from "@mantine/modals"
|
||||
import { useEffect } from "react"
|
||||
@@ -130,7 +144,12 @@ export function CategoryDetailsPage() {
|
||||
clearable
|
||||
withoutCategoryIds={excludedCategoryIds}
|
||||
/>
|
||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
||||
<NumberInput
|
||||
label={<Trans>Position</Trans>}
|
||||
{...form.getInputProps("position")}
|
||||
required
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -140,7 +159,11 @@ export function CategoryDetailsPage() {
|
||||
</Button>
|
||||
{editable && (
|
||||
<>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<TbDeviceFloppy size={16} />}
|
||||
loading={modifyCategory.loading}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
@@ -19,8 +19,8 @@ export function DonatePage() {
|
||||
<Trans>
|
||||
<Box>Hey,</Box>
|
||||
<Box mt="xs">
|
||||
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking
|
||||
an interest in helping me continue supporting CommaFeed.
|
||||
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years
|
||||
now. Thanks for taking an interest in helping me continue supporting CommaFeed.
|
||||
</Box>
|
||||
</Trans>
|
||||
|
||||
|
||||
@@ -133,7 +133,11 @@ export function FeedDetailsPage() {
|
||||
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
|
||||
<Box>
|
||||
{apiKey && (
|
||||
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
|
||||
<Anchor
|
||||
href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Trans>Link</Trans>
|
||||
</Anchor>
|
||||
)}
|
||||
@@ -146,10 +150,16 @@ export function FeedDetailsPage() {
|
||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
|
||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
||||
<ReceivePushNotificationsChechbox {...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })} />
|
||||
<ReceivePushNotificationsChechbox
|
||||
{...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={<Trans>Auto-mark as read</Trans>}
|
||||
description={<Trans>Mark entries in this feed as read after this number of days. Leave empty to disable.</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Mark entries in this feed as read after this number of days. Leave empty to disable.
|
||||
</Trans>
|
||||
}
|
||||
suffix={` ${_(msg`days`)}`}
|
||||
{...form.getInputProps("autoMarkAsReadAfterDays")}
|
||||
min={1}
|
||||
@@ -159,21 +169,25 @@ export function FeedDetailsPage() {
|
||||
label={<Trans>Filtering expression</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Build a filter expression to indicate what you want to read. Entries that don't match will be marked as read
|
||||
automatically.
|
||||
Build a filter expression to indicate what you want to read. Entries that don't match
|
||||
will be marked as read automatically.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{feed.filterLegacy && (
|
||||
<MantineAlert color="yellow" icon={<TbAlertTriangle />}>
|
||||
<Trans>
|
||||
This feed has a legacy filter that cannot be edited and is not applied. Please recreate the filter using
|
||||
the new expression editor. The legacy filter expression was: <Code>{feed.filterLegacy}</Code>
|
||||
This feed has a legacy filter that cannot be edited and is not applied. Please
|
||||
recreate the filter using the new expression editor. The legacy filter expression
|
||||
was: <Code>{feed.filterLegacy}</Code>
|
||||
</Trans>
|
||||
</MantineAlert>
|
||||
)}
|
||||
<Box mt="xs">
|
||||
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
|
||||
<FilteringExpressionEditor
|
||||
initialValue={feed.filter}
|
||||
onChange={value => form.setFieldValue("filter", value)}
|
||||
/>
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ function NoSubscriptionHelp() {
|
||||
<Box>
|
||||
<Center>
|
||||
<Trans>
|
||||
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
|
||||
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of
|
||||
the page?
|
||||
</Trans>
|
||||
</Center>
|
||||
</Box>
|
||||
@@ -51,7 +52,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
||||
const viewport = useViewportSize()
|
||||
const theme = useMantineTheme()
|
||||
const noSubscriptions = useAppSelector(
|
||||
state => state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
|
||||
state =>
|
||||
state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
|
||||
)
|
||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
|
||||
@@ -63,7 +65,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
||||
if (!root) return { unreadCount: 0, hasNewEntries: false }
|
||||
|
||||
if (props.sourceType === "category") {
|
||||
const category = id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
|
||||
const category =
|
||||
id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
|
||||
return {
|
||||
unreadCount: categoryUnreadCount(category),
|
||||
hasNewEntries: categoryHasNewEntries(category),
|
||||
|
||||
@@ -50,8 +50,8 @@ export function InitialSetupPage() {
|
||||
</Title>
|
||||
<Box mb="md">
|
||||
<Trans>
|
||||
Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get
|
||||
started.
|
||||
Welcome! This appears to be the first time you're running CommaFeed. Please create an
|
||||
administrator account to get started.
|
||||
</Trans>
|
||||
</Box>
|
||||
{setup.error && (
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { Link } from "react-router-dom"
|
||||
@@ -49,7 +61,11 @@ export function LoginPage() {
|
||||
placeholder={_(msg`User Name or E-mail`)}
|
||||
{...form.getInputProps("name")}
|
||||
description={
|
||||
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
|
||||
serverInfos?.demoAccountEnabled ? (
|
||||
<Trans>Try out CommaFeed with the demo account: demo/demo</Trans>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
required
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { Link } from "react-router-dom"
|
||||
@@ -71,7 +83,13 @@ export function RegistrationPage() {
|
||||
|
||||
<form onSubmit={form.onSubmit(register.execute)}>
|
||||
<Stack>
|
||||
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
|
||||
<TextInput
|
||||
label="User Name"
|
||||
placeholder="User Name"
|
||||
{...form.getInputProps("name")}
|
||||
size="md"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
type="email"
|
||||
label={<Trans>E-mail address</Trans>}
|
||||
|
||||
Reference in New Issue
Block a user