markCategoryAsRead(categoryId)}
17 | />
18 | )
19 | }
20 |
21 | export default Category
22 |
--------------------------------------------------------------------------------
/src/components/Article/ImageOverlayButton.css:
--------------------------------------------------------------------------------
1 | .icon-image {
2 | display: inline-block;
3 | width: auto;
4 | margin: 0;
5 | }
6 |
7 | .image-overlay-button {
8 | position: absolute;
9 | top: 0;
10 | left: 0;
11 | right: 0;
12 | bottom: 0;
13 | background-color: transparent;
14 | display: flex;
15 | justify-content: center;
16 | align-items: center;
17 | cursor: zoom-in;
18 | border: none;
19 | z-index: 1;
20 | }
21 |
22 | .image-wrapper {
23 | text-align: center;
24 | position: relative;
25 | }
26 |
27 | .image-container {
28 | display: inline-block;
29 | position: relative;
30 | width: 100%;
31 | }
32 |
--------------------------------------------------------------------------------
/src/utils/nanostores.js:
--------------------------------------------------------------------------------
1 | const createSetter =
2 | (store, key = null) =>
3 | (updater) => {
4 | const state = store.get()
5 |
6 | if (typeof state === "object" && state !== null) {
7 | if (key === null) {
8 | return
9 | }
10 | if (typeof updater === "function") {
11 | store.set({ ...state, [key]: updater(state[key]) })
12 | } else {
13 | store.set({ ...state, [key]: updater })
14 | }
15 | } else if (typeof updater === "function") {
16 | store.set(updater(state))
17 | } else {
18 | store.set(updater)
19 | }
20 | }
21 |
22 | export default createSetter
23 |
--------------------------------------------------------------------------------
/src/components/Settings/FeedList.css:
--------------------------------------------------------------------------------
1 | .edit-modal {
2 | width: 400px;
3 | }
4 |
5 | .feed-table {
6 | width: 100%;
7 | }
8 |
9 | .feed-table .arco-checkbox-mask {
10 | border-radius: 0 !important;
11 | }
12 |
13 | .feed-table-action-bar {
14 | display: flex;
15 | align-items: center;
16 | width: 100%;
17 | }
18 |
19 | .feed-table-action-bar .button-group {
20 | display: flex;
21 | gap: 8px;
22 | padding-bottom: 16px;
23 | padding-left: 8px;
24 | }
25 |
26 | .search-input {
27 | margin-bottom: 16px;
28 | width: 300px;
29 | }
30 |
31 | @media screen and (max-width: 768px) {
32 | .edit-modal {
33 | width: 95%;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/Article/ImageLinkTag.jsx:
--------------------------------------------------------------------------------
1 | import { Tag, Tooltip } from "@arco-design/web-react"
2 | import { IconLink } from "@arco-design/web-react/icon"
3 |
4 | import "./ImageLinkTag.css"
5 |
6 | const ImageLinkTag = ({ href }) => {
7 | if (href === "#") {
8 | return null
9 | }
10 |
11 | return (
12 |
13 | }
16 | onClick={(e) => {
17 | e.stopPropagation()
18 | window.open(href, "_blank")
19 | }}
20 | >
21 | {href}
22 |
23 |
24 | )
25 | }
26 |
27 | export default ImageLinkTag
28 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleTOC.css:
--------------------------------------------------------------------------------
1 | .toc-droplist-container {
2 | background-color: var(--color-neutral-1);
3 | border: 1px solid var(--color-fill-3);
4 | border-radius: var(--border-radius-medium);
5 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
6 | overflow: hidden;
7 | }
8 |
9 | .toc-filter-container {
10 | padding: 16px 16px 0;
11 | }
12 |
13 | .toc-menu-item {
14 | display: flex;
15 | align-items: center;
16 | width: 100%;
17 | }
18 |
19 | .toc-menu-container {
20 | max-height: 280px;
21 | width: 260px;
22 | }
23 |
24 | .toc-menu-container .arco-menu,
25 | .toc-menu-container .arco-menu-item {
26 | background-color: var(--color-neutral-1);
27 | }
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | pnpm-debug.log*
10 | lerna-debug.log*
11 |
12 | dist
13 | dist-ssr
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
27 | # dependencies
28 | /node_modules
29 | /.pnp
30 | .pnp.js
31 |
32 | # testing
33 | /coverage
34 |
35 | # production
36 | /build
37 |
38 | # development
39 | /dev-dist
40 |
41 | # misc
42 | stats.html
43 | src/version-info.json
44 |
--------------------------------------------------------------------------------
/src/hooks/usePhotoSlider.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { map } from "nanostores"
3 |
4 | import createSetter from "@/utils/nanostores"
5 |
6 | const state = map({ isPhotoSliderVisible: false, selectedIndex: 0 })
7 |
8 | const setIsPhotoSliderVisible = createSetter(state, "isPhotoSliderVisible")
9 | const setSelectedIndex = createSetter(state, "selectedIndex")
10 |
11 | const usePhotoSlider = () => {
12 | const { isPhotoSliderVisible, selectedIndex } = useStore(state)
13 |
14 | return {
15 | isPhotoSliderVisible,
16 | setIsPhotoSliderVisible,
17 | selectedIndex,
18 | setSelectedIndex,
19 | }
20 | }
21 |
22 | export default usePhotoSlider
23 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ReactFlux",
3 | "short_name": "ReactFlux",
4 | "description": "A Simple but Powerful RSS Reader for Miniflux",
5 | "icons": [
6 | {
7 | "src": "favicon.ico",
8 | "sizes": "64x64 32x32 24x24 16x16",
9 | "type": "image/x-icon"
10 | },
11 | {
12 | "src": "logo192.png",
13 | "type": "image/png",
14 | "sizes": "192x192"
15 | },
16 | {
17 | "src": "logo512.png",
18 | "type": "image/png",
19 | "sizes": "512x512"
20 | }
21 | ],
22 | "theme_color": "#1F2327",
23 | "background_color": "#ffffff",
24 | "display": "fullscreen",
25 | "fullscreen": "true",
26 | "start_url": "/"
27 | }
28 |
--------------------------------------------------------------------------------
/.github/workflows/translator.yml:
--------------------------------------------------------------------------------
1 | name: "translator"
2 |
3 | on:
4 | issues:
5 | types: [opened, edited]
6 | issue_comment:
7 | types: [created, edited]
8 | pull_request_target:
9 | types: [opened, edited]
10 | pull_request_review_comment:
11 | types: [created, edited]
12 |
13 | jobs:
14 | translate:
15 | permissions:
16 | issues: write
17 | discussions: write
18 | pull-requests: write
19 | runs-on: ubuntu-latest
20 | steps:
21 | - uses: lizheming/github-translate-action@1.1.2
22 | env:
23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24 | with:
25 | IS_MODIFY_TITLE: true
26 | APPEND_TRANSLATION: true
27 |
--------------------------------------------------------------------------------
/src/components/Main/Main.css:
--------------------------------------------------------------------------------
1 | .main {
2 | display: flex;
3 | grid-area: main;
4 | transition:
5 | padding-left 0.1s linear,
6 | width 0.1s linear;
7 | margin: 0;
8 | border: none;
9 | overflow: hidden;
10 | }
11 |
12 | .settings-modal {
13 | top: 5%;
14 | width: 720px;
15 | overflow-y: auto;
16 | }
17 |
18 | @media screen and (max-width: 992px) {
19 | .main {
20 | margin: 0;
21 | border: none;
22 | padding-left: 0;
23 | width: 100%;
24 | }
25 | }
26 |
27 | @media screen and (max-width: 768px) {
28 | .main {
29 | padding-bottom: env(safe-area-inset-bottom) !important;
30 | }
31 | .settings-modal {
32 | top: 8%;
33 | width: 95%;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: /
5 | schedule:
6 | interval: weekly
7 | day: friday
8 | time: "18:00"
9 | timezone: Asia/Shanghai
10 | ignore:
11 | - dependency-name: react
12 | versions: [">=19.0.0"]
13 | - dependency-name: react-dom
14 | versions: [">=19.0.0"]
15 | - dependency-name: "@types/react"
16 | versions: [">=19.0.0"]
17 | - dependency-name: "@types/react-dom"
18 | versions: [">=19.0.0"]
19 |
20 | - package-ecosystem: github-actions
21 | directory: /
22 | schedule:
23 | interval: weekly
24 | day: friday
25 | time: "18:00"
26 | timezone: Asia/Shanghai
27 |
--------------------------------------------------------------------------------
/.github/workflows/close-stale-issues.yml:
--------------------------------------------------------------------------------
1 | name: Close stale issues
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * *"
6 |
7 | workflow_dispatch:
8 |
9 | permissions:
10 | issues: write
11 |
12 | jobs:
13 | close_stale_issues:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/stale@v10
17 | with:
18 | repo-token: ${{ secrets.GITHUB_TOKEN }}
19 | stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days."
20 | close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity."
21 | days-before-issue-stale: 30
22 | days-before-issue-close: 5
23 |
--------------------------------------------------------------------------------
/.github/workflows/dockerhub-description.yml:
--------------------------------------------------------------------------------
1 | name: Update Docker Hub Description
2 | on:
3 | push:
4 | branches:
5 | - main
6 | paths:
7 | - README.md
8 | - .github/workflows/dockerhub-description.yml
9 |
10 | workflow_dispatch:
11 |
12 | jobs:
13 | dockerHubDescription:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v6
17 |
18 | - name: Docker Hub Description
19 | uses: peter-evans/dockerhub-description@v5
20 | with:
21 | username: ${{ secrets.DOCKERHUB_USERNAME }}
22 | password: ${{ secrets.DOCKERHUB_TOKEN }}
23 | repository: ${{ github.repository }}
24 | short-description: ${{ github.event.repository.description }}
25 | enable-url-completion: true
26 |
--------------------------------------------------------------------------------
/src/utils/form.js:
--------------------------------------------------------------------------------
1 | export const validateAndFormatFormFields = (form) => {
2 | // 获取当前表单所有字段的值
3 | const allFields = form.getFieldsValue()
4 | let isFormValid = true
5 |
6 | // 遍历所有字段,去除非密码字段的前后空格,并检查是否所有必填字段都已填写
7 | for (const [key, value] of Object.entries(allFields)) {
8 | if (key !== "password") {
9 | const trimmedValue = value?.trim()
10 | form.setFieldValue(key, trimmedValue) // 更新去除空格后的值
11 | if (!trimmedValue) {
12 | isFormValid = false // 如果去除空格后的必填字段为空,则表单不有效
13 | }
14 | } else if (!value) {
15 | isFormValid = false // 如果密码字段为空,则表单不有效
16 | }
17 | }
18 |
19 | return isFormValid
20 | }
21 |
22 | export const handleEnterKeyToSubmit = (event, form) => {
23 | if (event.key === "Enter") {
24 | form.submit()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/pages/Login.css:
--------------------------------------------------------------------------------
1 | .background {
2 | background-image: url("./images/background.jpg");
3 | background-position: center;
4 | background-size: cover;
5 | background-repeat: no-repeat;
6 | width: 50%;
7 | height: 100%;
8 | }
9 |
10 | .form-panel {
11 | display: flex;
12 | flex-direction: column;
13 | justify-content: center;
14 | align-items: center;
15 | z-index: 2;
16 | background-color: var(--color-bg-1);
17 | width: 50%;
18 | height: 100%;
19 | overflow-y: auto;
20 | }
21 |
22 | .login-form {
23 | width: 340px;
24 | }
25 |
26 | .page-layout {
27 | display: flex;
28 | height: 100%;
29 | }
30 |
31 | @media screen and (max-width: 768px) {
32 | .form-panel {
33 | position: absolute !important;
34 | z-index: 2 !important;
35 | width: 100% !important;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/hooks/useScreenWidth.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react"
2 |
3 | const MEDIUM_THRESHOLD = 768
4 | const LARGE_THRESHOLD = 992
5 |
6 | const useScreenWidth = () => {
7 | const [isBelowMedium, setIsBelowMedium] = useState(window.innerWidth <= MEDIUM_THRESHOLD)
8 | const [isBelowLarge, setIsBelowLarge] = useState(window.innerWidth <= LARGE_THRESHOLD)
9 |
10 | useEffect(() => {
11 | const handleResize = () => {
12 | setIsBelowMedium(window.innerWidth <= MEDIUM_THRESHOLD)
13 | setIsBelowLarge(window.innerWidth <= LARGE_THRESHOLD)
14 | }
15 |
16 | window.addEventListener("resize", handleResize)
17 | return () => window.removeEventListener("resize", handleResize)
18 | }, [])
19 |
20 | return { isBelowMedium, isBelowLarge }
21 | }
22 |
23 | export default useScreenWidth
24 |
--------------------------------------------------------------------------------
/src/components/Article/LoadingCards.jsx:
--------------------------------------------------------------------------------
1 | import { Card, Skeleton } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 |
4 | import { contentState } from "@/store/contentState"
5 | import "./LoadingCards.css"
6 |
7 | const LoadingCard = ({ isArticleListReady }) => (
8 |
9 |
12 | }
13 | />
14 |
15 | )
16 |
17 | const LoadingCards = () => {
18 | const { isArticleListReady } = useStore(contentState)
19 |
20 | return (
21 | !isArticleListReady &&
22 | Array.from({ length: 4 }, (_, index) => (
23 |
24 | ))
25 | )
26 | }
27 |
28 | export default LoadingCards
29 |
--------------------------------------------------------------------------------
/src/pages/ErrorPage.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Result } from "@arco-design/web-react"
2 | import { useEffect } from "react"
3 | import { useNavigate, useRouteError } from "react-router"
4 |
5 | import hideSpinner from "@/utils/loading"
6 |
7 | const ErrorPage = () => {
8 | const navigate = useNavigate()
9 | const error = useRouteError()
10 | console.error(error)
11 |
12 | useEffect(() => {
13 | hideSpinner()
14 | }, [])
15 |
16 | return (
17 |
18 | navigate("/")}>
23 | Back to Home
24 | ,
25 | ]}
26 | />
27 |
28 | )
29 | }
30 |
31 | export default ErrorPage
32 |
--------------------------------------------------------------------------------
/src/components/Sidebar/AddFeed.jsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@arco-design/web-react"
2 | import { IconPlus } from "@arco-design/web-react/icon"
3 | import { useStore } from "@nanostores/react"
4 |
5 | import CustomTooltip from "@/components/ui/CustomTooltip"
6 | import { polyglotState } from "@/hooks/useLanguage"
7 | import useModalToggle from "@/hooks/useModalToggle"
8 |
9 | export default function AddFeed() {
10 | const { setAddFeedModalVisible } = useModalToggle()
11 | const { polyglot } = useStore(polyglotState)
12 |
13 | return (
14 |
15 | }
17 | shape="circle"
18 | size="small"
19 | style={{ marginTop: "1em", marginBottom: "0.5em" }}
20 | onClick={() => setAddFeedModalVisible(true)}
21 | />
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useFeedIconsSync.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { useEffect } from "react"
3 | import { useLocation } from "react-router"
4 |
5 | import { setEntries } from "@/store/contentState"
6 | import { feedIconsState } from "@/store/feedIconsState"
7 |
8 | const useFeedIconsSync = () => {
9 | const feedIcons = useStore(feedIconsState)
10 |
11 | const { pathname } = useLocation()
12 |
13 | useEffect(() => {
14 | setEntries((prev) =>
15 | prev.map((entry) => {
16 | const feedIconId = entry.feed.icon.icon_id
17 | const feedIcon = feedIcons[feedIconId]
18 | if (entry.isMedia && feedIcon?.width) {
19 | return {
20 | ...entry,
21 | coverSource: feedIcon.url,
22 | }
23 | }
24 | return entry
25 | }),
26 | )
27 | }, [feedIcons, pathname])
28 | }
29 |
30 | export default useFeedIconsSync
31 |
--------------------------------------------------------------------------------
/src/hooks/useModalToggle.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { map } from "nanostores"
3 |
4 | import createSetter from "@/utils/nanostores"
5 |
6 | const state = map({
7 | addFeedModalVisible: false,
8 | settingsModalVisible: false,
9 | settingsTabsActiveTab: "1",
10 | })
11 |
12 | const setAddFeedModalVisible = createSetter(state, "addFeedModalVisible")
13 | const setSettingsModalVisible = createSetter(state, "settingsModalVisible")
14 | const setSettingsTabsActiveTab = createSetter(state, "settingsTabsActiveTab")
15 |
16 | const useModalToggle = () => {
17 | const { addFeedModalVisible, settingsModalVisible, settingsTabsActiveTab } = useStore(state)
18 |
19 | return {
20 | addFeedModalVisible,
21 | setAddFeedModalVisible,
22 | settingsModalVisible,
23 | setSettingsModalVisible,
24 | settingsTabsActiveTab,
25 | setSettingsTabsActiveTab,
26 | }
27 | }
28 |
29 | export default useModalToggle
30 |
--------------------------------------------------------------------------------
/src/utils/dom.js:
--------------------------------------------------------------------------------
1 | export const extractHeadings = (content) => {
2 | if (!content) {
3 | return []
4 | }
5 |
6 | const parser = new DOMParser()
7 | const doc = parser.parseFromString(content, "text/html")
8 | const headings = [...doc.querySelectorAll("h1, h2, h3, h4, h5, h6")]
9 |
10 | return headings.map((heading, index) => {
11 | const text = heading.textContent.trim()
12 | const level = Number.parseInt(heading.tagName.slice(1))
13 | const id = `heading-${index}`
14 |
15 | return {
16 | id,
17 | text,
18 | level,
19 | element: heading,
20 | }
21 | })
22 | }
23 |
24 | export const scrollToHeading = (heading) => {
25 | const headingElements = document.querySelectorAll("h1, h2, h3, h4, h5, h6")
26 |
27 | for (const element of headingElements) {
28 | if (element.textContent.trim() === heading.text) {
29 | element.scrollIntoView({ behavior: "smooth", block: "start" })
30 | break
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ui/CustomLink.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import { Link } from "react-router"
3 |
4 | const CustomLink = ({
5 | url,
6 | text,
7 | onMouseEnter = (e) => e.target.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })),
8 | onMouseLeave = (e) => e.target.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })),
9 | }) => {
10 | const [isHovering, setIsHovering] = useState(false)
11 |
12 | const handleMouseEnter = (e) => {
13 | setIsHovering(true)
14 | onMouseEnter(e)
15 | }
16 |
17 | const handleMouseLeave = (e) => {
18 | setIsHovering(false)
19 | onMouseLeave(e)
20 | }
21 |
22 | return (
23 |
32 | {text}
33 |
34 | )
35 | }
36 |
37 | export default CustomLink
38 |
--------------------------------------------------------------------------------
/src/pages/Today.jsx:
--------------------------------------------------------------------------------
1 | import { getTodayEntries, updateEntriesStatus } from "@/apis"
2 | import Content from "@/components/Content/Content"
3 |
4 | const getEntries = (status, _starred, filterParams) => getTodayEntries(status, filterParams)
5 |
6 | const markTodayAsRead = async () => {
7 | const unreadResponse = await getTodayEntries("unread")
8 | const unreadCount = unreadResponse.total
9 | let unreadEntries = unreadResponse.entries
10 |
11 | if (unreadCount > unreadEntries.length) {
12 | unreadEntries = getTodayEntries("unread", { limit: unreadCount }).then(
13 | (response) => response.entries,
14 | )
15 | }
16 |
17 | const unreadEntryIds = unreadEntries.map((entry) => entry.id)
18 | return updateEntriesStatus(unreadEntryIds, "read")
19 | }
20 |
21 | const Today = () => {
22 | return (
23 |
28 | )
29 | }
30 |
31 | export default Today
32 |
--------------------------------------------------------------------------------
/src/utils/url.js:
--------------------------------------------------------------------------------
1 | export const getHostname = (url) => {
2 | const pattern = /^(?:http|https):\/\/((?!(\d+\.){3}\d+)([^/?#]+))/
3 | const match = url.match(pattern)
4 | if (match) {
5 | return match[1]
6 | }
7 | return null
8 | }
9 |
10 | export const getSecondHostname = (url) => {
11 | const hostname = getHostname(url)
12 | if (hostname) {
13 | const parts = hostname.split(".")
14 | if (parts.length >= 2) {
15 | return parts.slice(-2).join(".")
16 | }
17 | }
18 | return null
19 | }
20 |
21 | export const extractBasePath = (pathname) => {
22 | return pathname.replace(/\/entry\/\d+$/, "")
23 | }
24 |
25 | export const buildEntryDetailPath = (basePath, entryId) => {
26 | return `${basePath}/entry/${entryId}`
27 | }
28 |
29 | export const isEntryDetailPath = (pathname) => {
30 | return /\/entry\/\d+$/.test(pathname)
31 | }
32 |
33 | export const extractEntryIdFromPath = (pathname) => {
34 | const match = pathname.match(/\/entry\/(\d+)$/)
35 | return match ? match[1] : null
36 | }
37 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | ::-webkit-input-placeholder,
2 | button,
3 | input,
4 | textarea {
5 | font-family:
6 | -apple-system,
7 | BlinkMacSystemFont,
8 | PingFang SC,
9 | Hiragino Sans GB,
10 | Microsoft YaHei,
11 | "\5FAE\8F6F\96C5\9ED1",
12 | helvetica neue,
13 | ubuntu,
14 | roboto,
15 | noto,
16 | segoe ui,
17 | Arial,
18 | sans-serif;
19 | }
20 |
21 | html {
22 | -webkit-font-smoothing: antialiased;
23 | -moz-osx-font-smoothing: grayscale;
24 | font-family:
25 | -apple-system,
26 | BlinkMacSystemFont,
27 | PingFang SC,
28 | Hiragino Sans GB,
29 | Microsoft YaHei,
30 | "\5FAE\8F6F\96C5\9ED1",
31 | helvetica neue,
32 | ubuntu,
33 | roboto,
34 | noto,
35 | segoe ui,
36 | Arial,
37 | sans-serif;
38 | }
39 |
40 | html,
41 | body,
42 | #root {
43 | margin: 0;
44 | height: 100%;
45 | overflow: hidden;
46 | overscroll-behavior: none;
47 | background-color: var(--color-bg-1);
48 | }
49 |
50 | a {
51 | margin-left: 0.25rem;
52 | margin-right: 0.25rem;
53 | }
54 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Stage 1: Build the React application
2 | # Specify the version to ensure consistent builds
3 | FROM --platform=$BUILDPLATFORM node:22-alpine AS build
4 |
5 | # Install git
6 | RUN apk add --no-cache git
7 |
8 | # enable corepack to use pnpm
9 | RUN corepack enable
10 |
11 | # Set the working directory in the container
12 | WORKDIR /app
13 |
14 | # Copy the package.json and pnpm-lock.yaml files
15 | COPY package.json pnpm-lock.yaml ./
16 |
17 | # Install dependencies using pnpm
18 | RUN pnpm install --frozen-lockfile
19 |
20 | # Copy the rest of the code
21 | COPY . .
22 |
23 | # Build the project
24 | RUN pnpm run build
25 |
26 | # Stage 2: Run the server using Caddy
27 | # Specify the version for consistency
28 | FROM caddy:2-alpine
29 |
30 | # Copy built assets from the builder stage
31 | COPY --from=build /app/build /srv
32 |
33 | # Caddy will pick up the Caddyfile automatically
34 | COPY Caddyfile /etc/caddy/Caddyfile
35 |
36 | # Expose the port Caddy listens on
37 | EXPOSE 2000
38 |
39 | CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
40 |
--------------------------------------------------------------------------------
/src/apis/index.js:
--------------------------------------------------------------------------------
1 | import apiClient from "./ofetch"
2 |
3 | export * from "./categories"
4 | export * from "./entries"
5 | export * from "./feeds"
6 |
7 | export const exportOPML = async () => apiClient.get("/v1/export")
8 |
9 | export const importOPML = (xmlContent) =>
10 | apiClient.raw("/v1/import", {
11 | method: "POST",
12 | body: xmlContent,
13 | })
14 |
15 | export const getCurrentUser = async () => apiClient.get("/v1/me")
16 |
17 | export const getFeedIcon = async (id) => apiClient.get(`/v1/icons/${id}`)
18 |
19 | export const getIntegrationsStatus = async () => apiClient.get("/v1/integrations/status")
20 |
21 | export const getVersion = async () => apiClient.get("/v1/version")
22 |
23 | export const markAllAsRead = async () => {
24 | const currentUser = await getCurrentUser()
25 | return apiClient.put(`/v1/users/${currentUser.id}/mark-all-as-read`)
26 | }
27 |
28 | export const saveEnclosureProgression = async (
29 | enclosureId,
30 | progress, // enclosureId: number, progress: number
31 | ) => apiClient.put(`/v1/enclosures/${enclosureId}`, { media_progression: progress })
32 |
--------------------------------------------------------------------------------
/src/components/Article/ActionButtons.css:
--------------------------------------------------------------------------------
1 | .action-buttons {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: space-between;
5 | z-index: 2;
6 | padding: 8px 10px;
7 | }
8 |
9 | .action-buttons .left-side,
10 | .action-buttons .right-side {
11 | display: flex;
12 | justify-content: space-between;
13 | gap: 8px;
14 | }
15 |
16 | .action-buttons .left-side .arco-btn-secondary.arco-btn-disabled {
17 | background-color: transparent;
18 | }
19 |
20 | .action-buttons .arco-btn-secondary:not(.arco-btn-disabled) {
21 | background-color: var(--color-bg-1);
22 | }
23 |
24 | .action-buttons.mobile {
25 | padding-left: 30px;
26 | padding-right: 30px;
27 | }
28 |
29 | .mobile-buttons {
30 | display: flex;
31 | justify-content: space-between;
32 | width: 100%;
33 | gap: 8px;
34 | }
35 |
36 | .settings-menu-item {
37 | display: flex;
38 | justify-content: space-between;
39 | align-items: center;
40 | width: 100%;
41 | gap: 10px;
42 | }
43 |
44 | .font-family-submenu .arco-dropdown-menu,
45 | .settings-dropdown .arco-dropdown-menu {
46 | background-color: var(--color-neutral-1);
47 | }
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 electh
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/apis/categories.js:
--------------------------------------------------------------------------------
1 | import apiClient from "./ofetch"
2 |
3 | import { dataState } from "@/store/dataState"
4 | import compareVersions from "@/utils/version"
5 |
6 | export const getCategories = async () => apiClient.get("/v1/categories")
7 |
8 | export const refreshCategoryFeed = async (id) =>
9 | apiClient.raw(`/v1/categories/${id}/refresh`, { method: "PUT" })
10 |
11 | export const addCategory = async (title) => apiClient.post("/v1/categories", { title })
12 |
13 | export const deleteCategory = async (id) =>
14 | apiClient.raw(`/v1/categories/${id}`, { method: "DELETE" })
15 |
16 | export const updateCategory = async (id, newTitle, hidden = false) => {
17 | const { version } = dataState.get()
18 |
19 | let hide_globally
20 |
21 | if (compareVersions(version, "2.2.8") >= 0) {
22 | hide_globally = hidden
23 | } else {
24 | hide_globally = hidden ? "on" : undefined
25 | }
26 |
27 | const params = { title: newTitle, hide_globally }
28 | return apiClient.put(`/v1/categories/${id}`, params)
29 | }
30 |
31 | export const markCategoryAsRead = async (id) =>
32 | apiClient.put(`/v1/categories/${id}/mark-all-as-read`)
33 |
--------------------------------------------------------------------------------
/.github/workflows/scheduled-docker-build.yml:
--------------------------------------------------------------------------------
1 | name: Scheduled Docker Build
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * *"
6 |
7 | workflow_dispatch:
8 |
9 | jobs:
10 | build-and-push:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v6
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v3
17 |
18 | - name: Log in to Docker Hub
19 | uses: docker/login-action@v3
20 | with:
21 | username: ${{ secrets.DOCKERHUB_USERNAME }}
22 | password: ${{ secrets.DOCKERHUB_TOKEN }}
23 |
24 | - name: Extract Docker metadata
25 | id: meta
26 | uses: docker/metadata-action@v5
27 | with:
28 | images: electh/reactflux
29 | tags: |
30 | type=raw,value={{date 'YYYY-MM-DD'}}
31 |
32 | - name: Build and push Docker image
33 | uses: docker/build-push-action@v6
34 | with:
35 | context: .
36 | push: true
37 | tags: ${{ steps.meta.outputs.tags }}
38 | platforms: linux/amd64,linux/arm64
39 | cache-from: type=gha,scope=docker-scheduled
40 | cache-to: type=gha,mode=max,scope=docker-scheduled
41 |
--------------------------------------------------------------------------------
/src/apis/feeds.js:
--------------------------------------------------------------------------------
1 | import apiClient from "./ofetch"
2 |
3 | export const getFeeds = async () => apiClient.get("/v1/feeds")
4 |
5 | export const getCounters = async () => apiClient.get("/v1/feeds/counters")
6 |
7 | export const refreshFeed = async (id) => apiClient.raw(`/v1/feeds/${id}/refresh`, { method: "PUT" })
8 |
9 | export const refreshAllFeed = async () => apiClient.raw("/v1/feeds/refresh", { method: "PUT" })
10 |
11 | export const addFeed = async (url, categoryId, isFullText) =>
12 | apiClient.post("/v1/feeds", {
13 | feed_url: url,
14 | category_id: categoryId,
15 | crawler: isFullText,
16 | })
17 |
18 | export const deleteFeed = async (id) => apiClient.raw(`/v1/feeds/${id}`, { method: "DELETE" })
19 |
20 | export const updateFeed = async (id, newDetails) => {
21 | const { categoryId, title, siteUrl, feedUrl, hidden, disabled, isFullText } = newDetails
22 |
23 | return apiClient.put(`/v1/feeds/${id}`, {
24 | category_id: categoryId,
25 | title,
26 | site_url: siteUrl,
27 | feed_url: feedUrl,
28 | hide_globally: hidden,
29 | disabled,
30 | crawler: isFullText,
31 | })
32 | }
33 |
34 | export const markFeedAsRead = async (id) => apiClient.put(`/v1/feeds/${id}/mark-all-as-read`)
35 |
--------------------------------------------------------------------------------
/.github/workflows/scheduled-tag.yml:
--------------------------------------------------------------------------------
1 | name: Scheduled Tag
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * *"
6 |
7 | workflow_dispatch:
8 |
9 | jobs:
10 | create-tag:
11 | runs-on: ubuntu-latest
12 | permissions:
13 | contents: write
14 | steps:
15 | - uses: actions/checkout@v6
16 | with:
17 | fetch-depth: 0
18 |
19 | - name: Get current date
20 | id: date
21 | run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
22 |
23 | - name: Check existing tag
24 | id: check_tag
25 | run: |
26 | LATEST_COMMIT=$(git rev-parse HEAD)
27 | TAGS_ON_COMMIT=$(git tag --points-at $LATEST_COMMIT)
28 | if [ ! -z "$TAGS_ON_COMMIT" ]; then
29 | echo "Tag(s) ${TAGS_ON_COMMIT} already exists on commit ${LATEST_COMMIT}, skipping..."
30 | echo "skip=true" >> $GITHUB_OUTPUT
31 | else
32 | echo "No tags found on latest commit, proceeding with tag creation"
33 | echo "skip=false" >> $GITHUB_OUTPUT
34 | fi
35 |
36 | - name: Create tag
37 | if: steps.check_tag.outputs.skip != 'true'
38 | run: |
39 | git tag v${{ steps.date.outputs.date }}
40 | git push origin v${{ steps.date.outputs.date }}
41 |
--------------------------------------------------------------------------------
/src/utils/locales.js:
--------------------------------------------------------------------------------
1 | export const getBrowserLanguage = () => {
2 | const browserLanguage = navigator.language
3 | if (browserLanguage === "zh-Hans-CN") {
4 | return "zh-CN"
5 | }
6 | return browserLanguage
7 | }
8 |
9 | // Determine priority type for sorting text
10 | const getTextType = (text) => {
11 | if (/^[0-9]/.test(text)) {
12 | return 0
13 | }
14 | if (/^[a-zA-Z]/.test(text)) {
15 | return 1
16 | }
17 | return 2
18 | }
19 |
20 | // Sorts array with mixed language content using a multi-level comparison
21 | export const sortMixedLanguageArray = (array, keyOrGetter, locale) => {
22 | // Create value extractor based on parameter type
23 | const getValueFromItem =
24 | typeof keyOrGetter === "function" ? keyOrGetter : (item) => item[keyOrGetter]
25 |
26 | return [...array].toSorted((itemA, itemB) => {
27 | const valueA = getValueFromItem(itemA)
28 | const valueB = getValueFromItem(itemB)
29 |
30 | const typeA = getTextType(valueA)
31 | const typeB = getTextType(valueB)
32 |
33 | // First sort by type priority
34 | if (typeA !== typeB) {
35 | return typeA - typeB
36 | }
37 |
38 | // Then sort by locale rules within the same type
39 | return valueA.localeCompare(valueB, locale, { numeric: true })
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/src/hooks/useFeedIcons.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { useEffect } from "react"
3 |
4 | import { getFeedIcon } from "@/apis"
5 | import { authState } from "@/store/authState"
6 | import { defaultIcon, feedIconsState } from "@/store/feedIconsState"
7 |
8 | const loadingIcons = new Set()
9 |
10 | const useFeedIcons = (id, feed = null) => {
11 | const auth = useStore(authState)
12 | const feedIcons = useStore(feedIconsState)
13 |
14 | useEffect(() => {
15 | if (feedIcons[id] || loadingIcons.has(id)) {
16 | return
17 | }
18 |
19 | loadingIcons.add(id)
20 |
21 | if (feed?.icon?.external_icon_id) {
22 | const iconURL = `${auth.server}/feed/icon/${feed.icon.external_icon_id}`
23 |
24 | feedIconsState.setKey(id, { ...defaultIcon, url: iconURL })
25 | loadingIcons.delete(id)
26 | } else {
27 | getFeedIcon(id)
28 | .then((data) => {
29 | const iconURL = `data:${data.data}`
30 | feedIconsState.setKey(id, { ...defaultIcon, url: iconURL })
31 | loadingIcons.delete(id)
32 | return null
33 | })
34 | .catch(() => {
35 | loadingIcons.delete(id)
36 | })
37 | }
38 |
39 | return () => {
40 | loadingIcons.delete(id)
41 | }
42 | }, [id])
43 |
44 | return feedIcons[id]
45 | }
46 |
47 | export default useFeedIcons
48 |
--------------------------------------------------------------------------------
/src/hooks/useTheme.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { useEffect, useState } from "react"
3 |
4 | import { settingsState } from "@/store/settingsState"
5 | import { applyColor } from "@/utils/colors"
6 |
7 | const useTheme = () => {
8 | const { themeColor, themeMode } = useStore(settingsState)
9 | const [isSystemDark, setIsSystemDark] = useState(
10 | globalThis.matchMedia("(prefers-color-scheme: dark)").matches,
11 | )
12 |
13 | useEffect(() => {
14 | const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: dark)")
15 | const updateSystemDarkMode = (event) => setIsSystemDark(event.matches)
16 |
17 | mediaQuery.addEventListener("change", updateSystemDarkMode)
18 |
19 | // 在组件卸载时清除监听器
20 | return () => mediaQuery.removeEventListener("change", updateSystemDarkMode)
21 | }, [])
22 |
23 | useEffect(() => {
24 | const applyColorScheme = (isDarkMode) => {
25 | const themeMode = isDarkMode ? "dark" : "light"
26 | document.body.setAttribute("arco-theme", themeMode)
27 | document.body.style.colorScheme = themeMode
28 | }
29 |
30 | applyColor(themeColor)
31 |
32 | if (themeMode === "system") {
33 | applyColorScheme(isSystemDark)
34 | } else {
35 | applyColorScheme(themeMode === "dark")
36 | }
37 | }, [isSystemDark, themeMode, themeColor])
38 | }
39 |
40 | export default useTheme
41 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy to gh-pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - ".editorconfig"
9 | - ".github/**"
10 | - ".gitignore"
11 | - ".husky/**"
12 | - ".prettierignore"
13 | - ".prettierrc"
14 | - "Caddyfile"
15 | - "Dockerfile"
16 | - "docs/**"
17 | - "eslint.config.mjs"
18 | - "README.md"
19 |
20 | workflow_run:
21 | workflows: ["Dependabot auto-merge"]
22 | types:
23 | - completed
24 |
25 | workflow_dispatch:
26 |
27 | permissions:
28 | contents: write
29 |
30 | jobs:
31 | build-and-deploy:
32 | concurrency: ci-${{github.ref}}
33 | runs-on: ubuntu-latest
34 | if: github.event.workflow_run.conclusion == 'success' || github.event_name != 'workflow_run'
35 | steps:
36 | - name: Checkout
37 | uses: actions/checkout@v6
38 |
39 | - name: Install pnpm
40 | run: npm install -g pnpm
41 |
42 | - name: Install and Build
43 | run: |
44 | pnpm install --frozen-lockfile
45 | pnpm run build
46 |
47 | - name: Deploy
48 | uses: JamesIves/github-pages-deploy-action@v4.7.6
49 | with:
50 | branch: gh-pages
51 | folder: build
52 | git-config-name: github-actions[bot]
53 | git-config-email: 41898282+github-actions[bot]@users.noreply.github.com
54 |
--------------------------------------------------------------------------------
/src/routes.jsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter, Navigate } from "react-router"
2 |
3 | import App from "./App"
4 | import All from "./pages/All"
5 | import Category from "./pages/Category"
6 | import ErrorPage from "./pages/ErrorPage"
7 | import Feed from "./pages/Feed"
8 | import History from "./pages/History"
9 | import Login from "./pages/Login"
10 | import RouterProtect from "./pages/RouterProtect"
11 | import Starred from "./pages/Starred"
12 | import Today from "./pages/Today"
13 | import { getSettings } from "./store/settingsState"
14 |
15 | const homePage = getSettings("homePage")
16 |
17 | const pageRoutes = {
18 | all: ,
19 | today: ,
20 | starred: ,
21 | history: ,
22 | "category/:id": ,
23 | "feed/:id": ,
24 | }
25 |
26 | const routes = Object.entries(pageRoutes).flatMap(([path, element]) => [
27 | { path: `/${path}`, element },
28 | { path: `/${path}/entry/:entryId`, element },
29 | ])
30 |
31 | const router = createBrowserRouter(
32 | [
33 | { path: "/login", element: },
34 | {
35 | path: "/",
36 | element: ,
37 | errorElement: ,
38 | children: [
39 | {
40 | element: ,
41 | children: [...routes, { index: true, element: }],
42 | },
43 | ],
44 | },
45 | ],
46 | {
47 | basename: import.meta.env.BASE_URL,
48 | },
49 | )
50 |
51 | export default router
52 |
--------------------------------------------------------------------------------
/src/utils/colors.js:
--------------------------------------------------------------------------------
1 | import { generate, getRgbStr } from "@arco-design/color"
2 |
3 | import { getSettings } from "@/store/settingsState"
4 |
5 | export const colors = {
6 | Red: { light: "#DC2626", dark: "#DC2626" },
7 | Orange: { light: "#F97316", dark: "#EA580C" },
8 | Yellow: { light: "#FACC15", dark: "#FACC15" },
9 | Green: { light: "#16A34A", dark: "#22C55E" },
10 | Blue: { light: "#2563EB", dark: "#3B82F6" },
11 | Violet: { light: "#722ED1", dark: "#8E51DA" },
12 | }
13 |
14 | const isDarkMode = () => {
15 | const isSystemDark = globalThis.matchMedia("(prefers-color-scheme: dark)").matches
16 | const themeMode = getSettings("themeMode")
17 | return themeMode === "system" ? isSystemDark : themeMode === "dark"
18 | }
19 |
20 | const getColorFromPalette = (palette, colorName, defaultColor = "Blue") => {
21 | const color = palette[colorName] || palette[defaultColor]
22 | return isDarkMode() ? color.dark : color.light
23 | }
24 |
25 | const getColorValue = (colorName) => getColorFromPalette(colors, colorName)
26 |
27 | export const getDisplayColorValue = (colorName) => getColorFromPalette(colors, colorName)
28 |
29 | export const applyColor = (colorName) => {
30 | const colorPalette = generate(getColorValue(colorName), { list: true }).map((color) =>
31 | getRgbStr(color),
32 | )
33 | for (const [index, color] of colorPalette.entries()) {
34 | document.body.style.setProperty(`--primary-${index + 1}`, color)
35 | }
36 | document.body.setAttribute("color-name", colorName)
37 | }
38 |
--------------------------------------------------------------------------------
/src/theme.css:
--------------------------------------------------------------------------------
1 | body {
2 | --border-radius-small: 8px;
3 | --border-radius-medium: 8px;
4 | --shadow:
5 | 0 0 5px 0 rgba(0, 0, 0, 0.02), 0 2px 10px 0 rgba(0, 0, 0, 0.07), 0 0 1px 0 rgba(0, 0, 0, 0.3);
6 | }
7 |
8 | body[arco-theme="dark"] {
9 | --shadow:
10 | 0 0 5px 0 rgba(0, 0, 0, 0.05), 0 2px 10px 0 rgba(0, 0, 0, 0.2),
11 | inset 0 0 1px 0 hsla(0, 0%, 100%, 0.15), 0 0 1px 0 rgba(255, 255, 255, 0.7);
12 | }
13 |
14 | body[color-name="Orange"] {
15 | --gray-1: 245 245 244;
16 | --gray-2: 231 229 228;
17 | --gray-3: 214 211 209;
18 | --gray-4: 168 162 158;
19 | --gray-5: 120 113 108;
20 | --gray-6: 87 83 78;
21 | --gray-7: 68 64 60;
22 | --gray-8: 41 37 36;
23 | --gray-9: 28 25 23;
24 | --gray-10: 12 10 9;
25 | --color-bg-1: rgb(250 250 249);
26 | --color-bg-2: rgb(250 250 249);
27 | --color-bg-3: rgb(250 250 249);
28 | --color-bg-4: rgb(250 250 249);
29 | --color-bg-5: rgb(250 250 249);
30 | }
31 |
32 | body[arco-theme="dark"][color-name="Orange"] {
33 | --gray-1: 28 25 23;
34 | --gray-2: 41 37 36;
35 | --gray-3: 68 64 60;
36 | --gray-4: 87 83 78;
37 | --gray-5: 120 113 108;
38 | --gray-6: 168 162 158;
39 | --gray-7: 214 211 209;
40 | --gray-8: 231 229 228;
41 | --gray-9: 245 245 244;
42 | --gray-10: 250 250 249;
43 |
44 | --color-bg-1: rgb(12 10 9);
45 | --color-bg-2: rgb(28 25 23);
46 | --color-bg-3: rgb(41 37 36);
47 | --color-bg-4: rgb(68 64 60);
48 | --color-bg-5: rgb(87 83 78);
49 | }
50 |
51 | body[arco-theme="dark"] .arco-radio-button::after {
52 | background-color: transparent;
53 | }
54 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from "node:path"
2 | import { fileURLToPath } from "node:url"
3 |
4 | import viteReact from "@vitejs/plugin-react"
5 | // import {visualizer} from "rollup-plugin-visualizer"
6 | import { defineConfig } from "vite"
7 | import { VitePWA } from "vite-plugin-pwa"
8 |
9 | const { dirname, resolve } = path
10 | const __filename = fileURLToPath(import.meta.url)
11 | const __dirname = dirname(__filename)
12 |
13 | const ReactCompilerConfig = { target: "18" }
14 |
15 | export default defineConfig({
16 | plugins: [
17 | viteReact({
18 | babel: {
19 | plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
20 | },
21 | }),
22 | VitePWA({
23 | registerType: "autoUpdate",
24 | devOptions: {
25 | enabled: false,
26 | },
27 | workbox: {
28 | skipWaiting: true,
29 | },
30 | }),
31 | // visualizer({
32 | // gzipSize: true,
33 | // }),
34 | ],
35 | resolve: {
36 | alias: {
37 | "@": resolve(__dirname, "./src"),
38 | },
39 | },
40 | server: {
41 | host: "0.0.0.0",
42 | port: 3000,
43 | },
44 | preview: {
45 | host: "0.0.0.0",
46 | port: 3000,
47 | },
48 | build: {
49 | outDir: "build",
50 | chunkSizeWarningLimit: 1000,
51 | rollupOptions: {
52 | output: {
53 | manualChunks: {
54 | arco: ["@arco-design/web-react"],
55 | highlight: ["highlight.js"],
56 | react: ["react", "react-dom", "react-router"],
57 | },
58 | },
59 | },
60 | },
61 | })
62 |
--------------------------------------------------------------------------------
/src/components/Article/SearchAndSortBar.css:
--------------------------------------------------------------------------------
1 | .search-and-sort-bar {
2 | display: flex;
3 | position: sticky;
4 | top: 0;
5 | align-items: center;
6 | z-index: 2;
7 | background-color: rgb(var(--color-bg-1));
8 | padding: 8px 0;
9 | }
10 |
11 | .search-and-sort-bar .page-info {
12 | display: flex;
13 | align-items: center;
14 | margin-left: 12px;
15 | color: var(--color-text-2);
16 | white-space: nowrap;
17 | overflow: hidden;
18 | flex: 1;
19 | }
20 |
21 | .search-and-sort-bar .page-info .title-container {
22 | overflow: hidden;
23 | }
24 |
25 | .search-and-sort-bar .page-info .title-container .arco-typography {
26 | display: block;
27 | overflow: hidden;
28 | text-overflow: ellipsis;
29 | white-space: nowrap;
30 | }
31 |
32 | .search-and-sort-bar .page-info .count-label {
33 | color: var(--color-text-4);
34 | margin-left: 6px;
35 | flex: 0 0 auto;
36 | font-weight: 500;
37 | display: inline-block;
38 | }
39 |
40 | .search-and-sort-bar .button-group {
41 | display: flex;
42 | flex: 0 0 auto;
43 | gap: 8px;
44 | margin: 0 8px;
45 | }
46 |
47 | .search-and-sort-bar .button-group button {
48 | background-color: transparent;
49 | }
50 |
51 | .calendar-actions {
52 | display: flex;
53 | justify-content: space-between;
54 | gap: 16px;
55 | color: var(--color-text-1);
56 | }
57 |
58 | .search-modal {
59 | width: 400px;
60 | }
61 |
62 | .arco-input-group-addbefore .arco-select .arco-select-view {
63 | border-radius: var(--border-radius-medium);
64 | }
65 |
66 | @media screen and (max-width: 768px) {
67 | .search-modal {
68 | width: 95%;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/apis/ofetch.js:
--------------------------------------------------------------------------------
1 | import { ofetch } from "ofetch"
2 |
3 | import router from "@/routes"
4 | import { authState } from "@/store/authState"
5 | import isValidAuth from "@/utils/auth"
6 |
7 | // 创建 ofetch 实例并设置默认配置
8 | const createApiClient = () => {
9 | return ofetch.create({
10 | retry: 3, // 默认重试次数
11 | onRequest({ _request, options }) {
12 | const auth = authState.get()
13 | if (!isValidAuth(auth)) {
14 | throw new Error("Invalid auth")
15 | }
16 | const { server, token, username, password } = auth
17 | options.baseURL = server
18 | options.headers = token
19 | ? { "X-Auth-Token": token }
20 | : { Authorization: `Basic ${btoa(`${username}:${password}`)}` }
21 | },
22 | onRequestError({ _request, _options, error }) {
23 | // 处理请求错误
24 | console.error("Request error:", error)
25 | },
26 | async onResponseError({ _request, response, _options }) {
27 | const statusCode = response.status
28 | if (statusCode === 401) {
29 | localStorage.removeItem("auth")
30 | await router.navigate("/login")
31 | }
32 | // 处理响应错误
33 | const errorMessage = response._data?.error_message ?? response.statusText
34 | console.error("Response error:", errorMessage)
35 | throw new Error(errorMessage)
36 | },
37 | })
38 | }
39 |
40 | const apiClient = createApiClient()
41 | apiClient.get = (url) => apiClient(url, { method: "GET" })
42 | apiClient.post = (url, body) => apiClient(url, { method: "POST", body })
43 | apiClient.put = (url, body) => apiClient(url, { method: "PUT", body })
44 |
45 | export default apiClient
46 |
--------------------------------------------------------------------------------
/src/hooks/useDocumentTitle.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { useEffect } from "react"
3 | import { useParams } from "react-router"
4 |
5 | import { polyglotState } from "@/hooks/useLanguage"
6 | import { contentState } from "@/store/contentState"
7 | import { categoriesState, feedsState } from "@/store/dataState"
8 |
9 | const BASE_TITLE = "ReactFlux"
10 |
11 | const useDocumentTitle = () => {
12 | const { activeContent, infoFrom } = useStore(contentState)
13 | const { polyglot } = useStore(polyglotState)
14 | const { id } = useParams()
15 | const feeds = useStore(feedsState)
16 | const categories = useStore(categoriesState)
17 |
18 | useEffect(() => {
19 | const getTitle = () => {
20 | if (activeContent?.title) {
21 | return activeContent.title
22 | }
23 |
24 | if (id) {
25 | if (infoFrom === "category") {
26 | return categories.find((c) => c.id === Number(id))?.title
27 | }
28 | if (infoFrom === "feed") {
29 | return feeds.find((f) => f.id === Number(id))?.title
30 | }
31 | }
32 |
33 | const pathToKey = {
34 | all: "sidebar.all",
35 | starred: "sidebar.starred",
36 | history: "sidebar.history",
37 | today: "sidebar.today",
38 | }
39 |
40 | const translationKey = pathToKey[infoFrom]
41 | return translationKey ? polyglot.t(translationKey) : ""
42 | }
43 |
44 | const title = getTitle()
45 | document.title = title ? `${title} - ${BASE_TITLE}` : BASE_TITLE
46 | }, [activeContent, infoFrom, id, feeds, categories, polyglot])
47 | }
48 |
49 | export default useDocumentTitle
50 |
--------------------------------------------------------------------------------
/src/hooks/useVersionCheck.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { ofetch } from "ofetch"
3 | import { useEffect, useState } from "react"
4 |
5 | import { dataState } from "@/store/dataState"
6 | import { GITHUB_REPO_PATH, UPDATE_NOTIFICATION_KEY } from "@/utils/constants"
7 | import { checkIsInLast24Hours, getTimestamp } from "@/utils/date"
8 | import buildInfo from "@/version-info.json"
9 |
10 | function useVersionCheck() {
11 | const { isAppDataReady } = useStore(dataState)
12 |
13 | const [hasUpdate, setHasUpdate] = useState(false)
14 |
15 | const dismissUpdate = () => {
16 | localStorage.setItem(UPDATE_NOTIFICATION_KEY, getTimestamp().toString())
17 | setHasUpdate(false)
18 | }
19 |
20 | useEffect(() => {
21 | if (!isAppDataReady || import.meta.env.DEV) {
22 | return
23 | }
24 |
25 | const checkUpdate = async () => {
26 | try {
27 | const lastDismissed = localStorage.getItem(UPDATE_NOTIFICATION_KEY)
28 | if (lastDismissed && checkIsInLast24Hours(lastDismissed)) {
29 | return
30 | }
31 |
32 | const data = await ofetch(`https://api.github.com/repos/${GITHUB_REPO_PATH}/commits/main`)
33 |
34 | const currentGitTimestamp = getTimestamp(buildInfo.gitDate)
35 | const latestGitTimestamp = getTimestamp(data.commit.committer.date)
36 |
37 | setHasUpdate(currentGitTimestamp < latestGitTimestamp)
38 | } catch (error) {
39 | console.error("Check update failed", error)
40 | }
41 | }
42 |
43 | checkUpdate()
44 | }, [isAppDataReady])
45 |
46 | return { hasUpdate, dismissUpdate }
47 | }
48 |
49 | export default useVersionCheck
50 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-docker.yml:
--------------------------------------------------------------------------------
1 | name: Push Docker image to Docker Hub
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - ".editorconfig"
9 | - ".github/**"
10 | - ".gitignore"
11 | - ".husky/**"
12 | - ".prettierignore"
13 | - ".prettierrc"
14 | - "docs/**"
15 | - "eslint.config.mjs"
16 | - "README.md"
17 |
18 | workflow_run:
19 | workflows: ["Dependabot auto-merge"]
20 | types:
21 | - completed
22 |
23 | workflow_dispatch:
24 |
25 | jobs:
26 | build-and-push:
27 | runs-on: ubuntu-latest
28 | if: github.event.workflow_run.conclusion == 'success' || github.event_name != 'workflow_run'
29 | steps:
30 | - uses: actions/checkout@v6
31 |
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v3
34 |
35 | - name: Log in to Docker Hub
36 | uses: docker/login-action@v3
37 | with:
38 | username: ${{ secrets.DOCKERHUB_USERNAME }}
39 | password: ${{ secrets.DOCKERHUB_TOKEN }}
40 |
41 | - name: Extract Docker metadata
42 | id: meta
43 | uses: docker/metadata-action@v5
44 | with:
45 | images: electh/reactflux
46 | tags: |
47 | type=raw,value=latest,enable=true
48 | type=sha,format=long,prefix=,enable=true
49 |
50 | - name: Build and push Docker image
51 | uses: docker/build-push-action@v6
52 | with:
53 | context: .
54 | push: true
55 | tags: ${{ steps.meta.outputs.tags }}
56 | platforms: linux/amd64,linux/arm64
57 | cache-from: type=gha,scope=docker-release
58 | cache-to: type=gha,mode=max,scope=docker-release
59 |
--------------------------------------------------------------------------------
/src/components/Content/Content.css:
--------------------------------------------------------------------------------
1 | .article-container {
2 | display: flex;
3 | flex-direction: column;
4 | box-sizing: border-box;
5 | background-color: var(--color-bg-1);
6 | width: 100%;
7 | box-shadow: var(--shadow);
8 | border-radius: var(--border-radius-medium);
9 | margin: 10px 10px 10px 0;
10 | min-height: 0;
11 | }
12 |
13 | .content-empty {
14 | display: flex;
15 | flex: 1;
16 | flex-direction: column;
17 | justify-content: center;
18 | align-items: center;
19 | padding: 40px 16px;
20 | color: var(--color-text-3);
21 | background-color: var(--color-bg-1);
22 | box-shadow: var(--shadow);
23 | border-radius: var(--border-radius-medium);
24 | margin: 10px 10px 10px 0;
25 | }
26 |
27 | .entry-col {
28 | display: flex;
29 | flex-direction: column;
30 | }
31 |
32 | .swipe-hint {
33 | --hint-size: 24px;
34 | --hint-padding: 12px;
35 | position: fixed;
36 | top: 50%;
37 | transform: translateY(-50%);
38 | padding: var(--hint-padding);
39 | background-color: rgb(var(--primary-6));
40 | border-radius: 50%;
41 | pointer-events: none;
42 | z-index: 100;
43 | width: var(--hint-size);
44 | height: var(--hint-size);
45 | }
46 |
47 | .swipe-hint.left {
48 | left: 16px;
49 | }
50 |
51 | .swipe-hint.right {
52 | right: 16px;
53 | }
54 |
55 | @media screen and (max-width: 768px) {
56 | .article-container {
57 | position: absolute !important;
58 | z-index: 3 !important;
59 | height: 100% !important;
60 | border-radius: 0;
61 | box-shadow: none;
62 | margin: 0;
63 | padding-bottom: env(safe-area-inset-bottom) !important;
64 | }
65 | .content-empty {
66 | display: none;
67 | }
68 | .entry-col {
69 | border: none;
70 | width: 100%;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/Settings/Hotkeys.jsx:
--------------------------------------------------------------------------------
1 | import { Table } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 |
4 | import EditableTagGroup from "./EditableTagGroup"
5 |
6 | import { polyglotState } from "@/hooks/useLanguage"
7 | import { hotkeysState } from "@/store/hotkeysState"
8 |
9 | const Hotkeys = () => {
10 | const { polyglot } = useStore(polyglotState)
11 | const hotkeys = useStore(hotkeysState)
12 |
13 | const hotkeyActions = [
14 | "navigateToPreviousArticle",
15 | "navigateToPreviousUnreadArticle",
16 | "navigateToNextArticle",
17 | "navigateToNextUnreadArticle",
18 | "openLinkExternally",
19 | "toggleReadStatus",
20 | "fetchOriginalArticle",
21 | "toggleStarStatus",
22 | "saveToThirdPartyServices",
23 | "openPhotoSlider",
24 | "refreshArticleList",
25 | "exitDetailView",
26 | "showHotkeysSettings",
27 | ]
28 |
29 | const columns = [
30 | {
31 | title: polyglot.t("hotkeys.key"),
32 | dataIndex: "keys",
33 | fixed: "left",
34 | width: "50%",
35 | render: (keys, record) => ,
36 | },
37 | {
38 | title: polyglot.t("hotkeys.function"),
39 | dataIndex: "description",
40 | },
41 | ]
42 |
43 | const hotkeysMapping = hotkeyActions.map((action) => ({
44 | action,
45 | key: action,
46 | keys: hotkeys[action],
47 | description: polyglot.t(`hotkeys.${action}`),
48 | }))
49 |
50 | return (
51 |
59 | )
60 | }
61 |
62 | export default Hotkeys
63 |
--------------------------------------------------------------------------------
/src/store/settingsState.js:
--------------------------------------------------------------------------------
1 | import { persistentAtom } from "@nanostores/persistent"
2 |
3 | import { getBrowserLanguage } from "@/utils/locales"
4 |
5 | const defaultValue = {
6 | articleWidth: 75,
7 | compactSidebarGroups: true,
8 | coverDisplayMode: "auto",
9 | edgeToEdgeImages: false,
10 | enableContextMenu: true,
11 | enableSwipeGesture: true,
12 | fontFamily: "system-ui",
13 | fontSize: 1.05,
14 | homePage: "all",
15 | language: getBrowserLanguage(),
16 | lightboxSlideAnimation: true,
17 | markReadBy: "view",
18 | markReadOnScroll: false,
19 | orderBy: "created_at",
20 | orderDirection: "desc",
21 | pageSize: 100,
22 | removeDuplicates: "none",
23 | showDetailedRelativeTime: false,
24 | showEstimatedReadingTime: false,
25 | showFeedIcon: true,
26 | showHiddenFeeds: false,
27 | showStatus: "unread",
28 | showUnreadFeedsOnly: false,
29 | swipeSensitivity: 1,
30 | themeColor: "Blue",
31 | themeMode: "system",
32 | titleAlignment: "center",
33 | updateContentOnFetch: false,
34 | }
35 |
36 | export const settingsState = persistentAtom("settings", defaultValue, {
37 | encode: (value) => {
38 | const filteredValue = {}
39 |
40 | for (const key in value) {
41 | if (key in defaultValue) {
42 | filteredValue[key] = value[key]
43 | }
44 | }
45 |
46 | return JSON.stringify(filteredValue)
47 | },
48 | decode: (str) => {
49 | const storedValue = JSON.parse(str)
50 | return { ...defaultValue, ...storedValue }
51 | },
52 | })
53 |
54 | export const getSettings = (key) => settingsState.get()[key]
55 |
56 | export const updateSettings = (settingsChanges) =>
57 | settingsState.set({ ...settingsState.get(), ...settingsChanges })
58 |
59 | export const resetSettings = () => settingsState.set(defaultValue)
60 |
--------------------------------------------------------------------------------
/src/components/Article/SidebarTrigger.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Drawer } from "@arco-design/web-react"
2 | import { IconMenu } from "@arco-design/web-react/icon"
3 | import { useStore } from "@nanostores/react"
4 | import { atom } from "nanostores"
5 | import { useEffect } from "react"
6 | import { useLocation } from "react-router"
7 |
8 | import Sidebar from "@/components/Sidebar/Sidebar"
9 | import useScreenWidth from "@/hooks/useScreenWidth"
10 | import "./SidebarTrigger.css"
11 | import createSetter from "@/utils/nanostores"
12 |
13 | const sidebarVisibleState = atom(false)
14 | const setSidebarVisible = createSetter(sidebarVisibleState)
15 |
16 | export default function SidebarTrigger() {
17 | const currentPath = useLocation().pathname
18 | const { isBelowLarge } = useScreenWidth()
19 |
20 | const sidebarVisible = useStore(sidebarVisibleState)
21 |
22 | useEffect(() => {
23 | if (!isBelowLarge) {
24 | setSidebarVisible(false)
25 | }
26 | }, [isBelowLarge])
27 |
28 | useEffect(() => {
29 | if (currentPath) {
30 | setSidebarVisible(false)
31 | }
32 | }, [currentPath])
33 |
34 | return (
35 |
36 |
37 |
45 |
46 |
setSidebarVisible(false)}
55 | >
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/store/hotkeysState.js:
--------------------------------------------------------------------------------
1 | import { persistentAtom } from "@nanostores/persistent"
2 | import { computed } from "nanostores"
3 |
4 | import createSetter from "@/utils/nanostores"
5 |
6 | const defaultValue = {
7 | exitDetailView: ["esc"],
8 | fetchOriginalArticle: ["d"],
9 | navigateToNextArticle: ["n", "j", "right"],
10 | navigateToNextUnreadArticle: ["shift+n", "shift+j", "ctrl+right"],
11 | navigateToPreviousArticle: ["p", "k", "left"],
12 | navigateToPreviousUnreadArticle: ["shift+p", "shift+k", "ctrl+left"],
13 | openLinkExternally: ["v"],
14 | openPhotoSlider: ["i"],
15 | refreshArticleList: ["r"],
16 | saveToThirdPartyServices: ["s"],
17 | showHotkeysSettings: ["shift+?"],
18 | toggleReadStatus: ["m"],
19 | toggleStarStatus: ["f"],
20 | }
21 |
22 | export const hotkeysState = persistentAtom("hotkeys", defaultValue, {
23 | encode: (value) => {
24 | const filteredValue = {}
25 |
26 | for (const key of Object.keys(value)) {
27 | if (key in defaultValue) {
28 | filteredValue[key] = value[key]
29 | }
30 | }
31 |
32 | return JSON.stringify(filteredValue)
33 | },
34 | decode: (str) => {
35 | const storedValue = JSON.parse(str)
36 | return { ...defaultValue, ...storedValue }
37 | },
38 | })
39 |
40 | export const duplicateHotkeysState = computed(hotkeysState, (hotkeys) => {
41 | const allKeys = Object.values(hotkeys).flat()
42 | const keyCount = {}
43 |
44 | for (const key of allKeys) {
45 | keyCount[key] = (keyCount[key] || 0) + 1
46 | }
47 |
48 | return Object.entries(keyCount)
49 | .filter(([_key, count]) => count > 1)
50 | .map(([key]) => key)
51 | })
52 |
53 | export const updateHotkey = (action, keys) => {
54 | const setter = createSetter(hotkeysState, action)
55 | setter(keys)
56 | }
57 | export const resetHotkey = (action) => updateHotkey(action, defaultValue[action])
58 |
--------------------------------------------------------------------------------
/src/components/ui/Ripple.jsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useState } from "react"
2 |
3 | import "./Ripple.css"
4 |
5 | const useDebouncedRippleCleanUp = (rippleCount, duration, cleanUpFunction) => {
6 | useLayoutEffect(() => {
7 | let bounce = null
8 | if (rippleCount > 0) {
9 | clearTimeout(bounce)
10 |
11 | bounce = setTimeout(() => {
12 | cleanUpFunction()
13 | clearTimeout(bounce)
14 | }, duration * 4)
15 | }
16 |
17 | return () => clearTimeout(bounce)
18 | }, [rippleCount, duration, cleanUpFunction])
19 | }
20 |
21 | const Ripple = ({ duration, color }) => {
22 | const [rippleArray, setRippleArray] = useState([])
23 |
24 | useDebouncedRippleCleanUp(rippleArray.length, duration, () => {
25 | setRippleArray([])
26 | })
27 |
28 | const addRipple = (event) => {
29 | const rippleContainer = event.currentTarget.getBoundingClientRect()
30 | const size = Math.max(rippleContainer.width, rippleContainer.height)
31 | const x = event.pageX - rippleContainer.x - size / 2
32 | const y = event.pageY - rippleContainer.y - size / 2
33 | const newRipple = {
34 | x,
35 | y,
36 | size,
37 | id: `${Date.now()}-${x}-${y}`,
38 | }
39 |
40 | setRippleArray([...rippleArray, newRipple])
41 | }
42 |
43 | return (
44 |
45 | {rippleArray.length > 0 &&
46 | rippleArray.map((ripple) => {
47 | return (
48 |
60 | )
61 | })}
62 |
63 | )
64 | }
65 |
66 | export default Ripple
67 |
--------------------------------------------------------------------------------
/public/styles/loading.css:
--------------------------------------------------------------------------------
1 | /*Copyright - 2024 satyamchaudharydev (satyam)*/
2 |
3 | /*Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:*/
4 |
5 | /*The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.*/
6 |
7 | /*THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/
8 |
9 | .spinner {
10 | --gap: 5px;
11 | --clr: #165dff;
12 | --height: 23px;
13 | display: flex;
14 | position: absolute;
15 | top: 50%;
16 | left: 50%;
17 | justify-content: center;
18 | align-items: center;
19 | gap: var(--gap);
20 | transform: translate(-50%, -50%);
21 | width: 100px;
22 | height: 100px;
23 | }
24 |
25 | .spinner span {
26 | animation: grow 1s ease-in-out infinite;
27 | background: var(--clr);
28 | width: 6px;
29 | height: var(--height);
30 | }
31 |
32 | .spinner span:nth-child(2) {
33 | animation: grow 1s ease-in-out 0.15s infinite;
34 | }
35 |
36 | .spinner span:nth-child(3) {
37 | animation: grow 1s ease-in-out 0.3s infinite;
38 | }
39 |
40 | .spinner span:nth-child(4) {
41 | animation: grow 1s ease-in-out 0.475s infinite;
42 | }
43 |
44 | @keyframes grow {
45 | 0%,
46 | 100% {
47 | transform: scaleY(1);
48 | }
49 |
50 | 50% {
51 | transform: scaleY(1.8);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: "💡 Feature Request"
2 | description: "Suggest an idea for ReactFlux"
3 | title: "[Feature] "
4 | labels: ["enhancement"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to suggest a new feature!
10 |
11 | - type: textarea
12 | id: problem
13 | attributes:
14 | label: Problem Statement
15 | description: Is your feature request related to a problem? Please describe what you're trying to achieve.
16 | validations:
17 | required: true
18 |
19 | - type: textarea
20 | id: solution
21 | attributes:
22 | label: Proposed Solution
23 | description: A clear and concise description of what you want to happen. Include any mockups or sketches if applicable.
24 | validations:
25 | required: true
26 |
27 | - type: textarea
28 | id: use-cases
29 | attributes:
30 | label: Use Cases
31 | description: Describe specific scenarios where this feature would be useful
32 | placeholder: |
33 | 1. When I'm...
34 | 2. As a user who...
35 | 3. In situations where...
36 | validations:
37 | required: true
38 |
39 | - type: textarea
40 | id: alternatives
41 | attributes:
42 | label: Alternative Solutions
43 | description: Have you considered any alternative solutions or workarounds?
44 | validations:
45 | required: false
46 |
47 | - type: textarea
48 | id: implementation
49 | attributes:
50 | label: Implementation Ideas
51 | description: If you have any technical suggestions for implementation, please share them.
52 | validations:
53 | required: false
54 |
55 | - type: textarea
56 | id: additional
57 | attributes:
58 | label: Additional Context
59 | description: |
60 | Please provide any additional context:
61 | - How would this feature benefit other users?
62 | - Are there any potential drawbacks?
63 | - Any other relevant information or screenshots
64 | validations:
65 | required: false
66 |
--------------------------------------------------------------------------------
/src/hooks/useLanguage.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import dayjs from "dayjs"
3 | import "dayjs/locale/de"
4 | import "dayjs/locale/en"
5 | import "dayjs/locale/es"
6 | import "dayjs/locale/fr"
7 | import "dayjs/locale/zh-cn"
8 | import { map } from "nanostores"
9 | import Polyglot from "node-polyglot"
10 | import { useEffect } from "react"
11 |
12 | import { settingsState, updateSettings } from "@/store/settingsState"
13 | import { getBrowserLanguage } from "@/utils/locales"
14 | import createSetter from "@/utils/nanostores"
15 |
16 | const languageToLocale = {
17 | "zh-CN": "zh-cn",
18 | de: "de",
19 | es: "es",
20 | fr: "fr",
21 | }
22 |
23 | export const polyglotState = map({
24 | polyglot: null,
25 | })
26 | const setPolyglot = createSetter(polyglotState, "polyglot")
27 |
28 | const loadLanguage = async (language, polyglot) => {
29 | let phrases
30 | let locale = language
31 |
32 | try {
33 | const phrasesModule = await import(`../locales/${language}.json`)
34 | phrases = phrasesModule.default
35 | } catch (error) {
36 | console.error("Failed to load language:", error)
37 | const fallbackModule = await import("@/locales/en-US.json")
38 | phrases = fallbackModule.default
39 | locale = "en-US"
40 | }
41 |
42 | if (polyglot) {
43 | polyglot.replace(phrases)
44 | polyglot.locale(locale)
45 | setPolyglot(polyglot)
46 | } else {
47 | const newPolyglot = new Polyglot({
48 | phrases: phrases,
49 | locale: locale,
50 | })
51 | setPolyglot(newPolyglot)
52 | }
53 | }
54 |
55 | const useLanguage = () => {
56 | const { language } = useStore(settingsState)
57 |
58 | useEffect(() => {
59 | if (language) {
60 | loadLanguage(language)
61 |
62 | const locale =
63 | language.startsWith("de-") || language.startsWith("es-") || language.startsWith("fr-")
64 | ? language.slice(0, 2)
65 | : languageToLocale[language] || "en"
66 | dayjs.locale(locale)
67 | } else {
68 | updateSettings({ language: getBrowserLanguage() })
69 | }
70 | }, [language])
71 | }
72 |
73 | export default useLanguage
74 |
--------------------------------------------------------------------------------
/src/components/ui/EditCategoryModal.jsx:
--------------------------------------------------------------------------------
1 | import { Form, Input, Modal, Switch } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 |
4 | import useCategoryOperations from "@/hooks/useCategoryOperations"
5 | import { polyglotState } from "@/hooks/useLanguage"
6 |
7 | const EditCategoryModal = ({
8 | visible,
9 | setVisible,
10 | categoryForm,
11 | selectedCategory,
12 | onSuccess,
13 | useNotification = false,
14 | showHiddenField = true,
15 | }) => {
16 | const { polyglot } = useStore(polyglotState)
17 | const { editCategory } = useCategoryOperations(useNotification)
18 |
19 | const handleCancel = () => {
20 | setVisible(false)
21 | categoryForm.resetFields()
22 | }
23 |
24 | const handleSubmit = async (values) => {
25 | const success = await editCategory(selectedCategory.id, values.title, values.hidden)
26 | if (success) {
27 | setVisible(false)
28 | categoryForm.resetFields()
29 | if (onSuccess) {
30 | onSuccess()
31 | }
32 | }
33 | }
34 |
35 | if (!selectedCategory) {
36 | return null
37 | }
38 |
39 | const modalTitle = polyglot.t("category_list.edit_category_title")
40 | const titleLabel = polyglot.t("category_list.edit_category_title_label")
41 | const titlePlaceholder = polyglot.t("category_list.edit_category_title_placeholder")
42 | const hiddenLabel = polyglot.t("category_list.edit_category_hidden_label")
43 |
44 | return (
45 |
54 |
56 |
57 |
58 |
59 | {showHiddenField && (
60 |
67 |
68 |
69 | )}
70 |
71 |
72 | )
73 | }
74 |
75 | export default EditCategoryModal
76 |
--------------------------------------------------------------------------------
/src/utils/deduplicate.js:
--------------------------------------------------------------------------------
1 | import { Message } from "@arco-design/web-react"
2 |
3 | import { updateEntriesStatus } from "@/apis"
4 | import { handleEntriesStatusUpdate } from "@/hooks/useEntryActions"
5 | import { polyglotState } from "@/hooks/useLanguage"
6 |
7 | const removeDuplicateEntries = (entries, option) => {
8 | const { polyglot } = polyglotState.get()
9 |
10 | if (entries.length === 0 || option === "none") {
11 | return entries
12 | }
13 |
14 | const originalOrder = entries.map((entry, index) => ({
15 | id: entry.id,
16 | index,
17 | }))
18 |
19 | const seenHashes = new Map()
20 | const seenTitles = new Map()
21 | const seenURLs = new Map()
22 | const duplicateEntries = []
23 |
24 | const uniqueEntries = [...entries]
25 | .toSorted((a, b) => a.id - b.id)
26 | .filter((entry) => {
27 | const { hash, title, url, id } = entry
28 |
29 | switch (option) {
30 | case "hash": {
31 | if (seenHashes.has(hash)) {
32 | duplicateEntries.push(entry)
33 | return false
34 | }
35 | seenHashes.set(hash, id)
36 | break
37 | }
38 | case "title": {
39 | if (seenTitles.has(title)) {
40 | duplicateEntries.push(entry)
41 | return false
42 | }
43 | seenTitles.set(title, id)
44 | break
45 | }
46 | case "url": {
47 | if (seenURLs.has(url)) {
48 | duplicateEntries.push(entry)
49 | return false
50 | }
51 | seenURLs.set(url, id)
52 | break
53 | }
54 | default: {
55 | return true
56 | }
57 | }
58 | return true
59 | })
60 |
61 | const unreadDuplicateIds = duplicateEntries
62 | .filter((entry) => entry.status === "unread")
63 | .map((entry) => entry.id)
64 | if (unreadDuplicateIds.length > 0) {
65 | handleEntriesStatusUpdate(duplicateEntries, "read")
66 | updateEntriesStatus(unreadDuplicateIds, "read").catch(() => {
67 | Message.error(polyglot.t("deduplicate.mark_as_read_error"))
68 | handleEntriesStatusUpdate(duplicateEntries, "unread")
69 | })
70 | }
71 |
72 | return uniqueEntries.toSorted((a, b) => {
73 | const indexA = originalOrder.find((order) => order.id === a.id).index
74 | const indexB = originalOrder.find((order) => order.id === b.id).index
75 | return indexA - indexB
76 | })
77 | }
78 |
79 | export default removeDuplicateEntries
80 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .app {
2 | display: grid;
3 | grid-template-columns: 240px 1fr;
4 | grid-template-areas: "sidebar main";
5 | background-color: var(--color-neutral-2);
6 | height: 100%;
7 | }
8 |
9 | .arco-btn-secondary:not(.arco-btn-disabled):has(svg) {
10 | background-color: transparent;
11 | }
12 |
13 | .arco-layout-sider-light {
14 | box-shadow: none !important;
15 | }
16 |
17 | .arco-menu-collapse {
18 | width: 0 !important;
19 | }
20 |
21 | .arco-menu-collapse-button {
22 | display: none;
23 | }
24 |
25 | .arco-pagination {
26 | display: flex;
27 | justify-content: space-between;
28 | width: 90%;
29 | }
30 |
31 | .simplebar-scrollbar::before {
32 | transition-delay: 1s;
33 | background-color: #959595;
34 | min-height: 30px;
35 | }
36 |
37 | @media screen and (display-mode: fullscreen) {
38 | * {
39 | -webkit-tap-highlight-color: transparent;
40 | }
41 |
42 | body {
43 | min-height: 100vh;
44 | background-color: var(--color-bg-1);
45 | }
46 |
47 | .app {
48 | display: grid;
49 | grid-template-columns: 240px 1fr;
50 | grid-template-areas: "sidebar main";
51 | border-radius: var(--border-radius-medium);
52 | height: 100%;
53 | overflow: hidden;
54 | }
55 | }
56 |
57 | @supports (padding-top: env(safe-area-inset-top)) {
58 | @media screen and (display-mode: fullscreen) {
59 | .app {
60 | padding-right: env(safe-area-inset-right);
61 | padding-left: env(safe-area-inset-left);
62 | }
63 |
64 | .arco-menu-light {
65 | padding-top: max(env(safe-area-inset-top), 20px);
66 | padding-left: env(safe-area-inset-left);
67 | }
68 |
69 | /* .main {
70 | padding-top: max(env(safe-area-inset-top), 20px);
71 | } */
72 | }
73 | }
74 |
75 | @media screen and (max-width: 992px) {
76 | .app {
77 | grid-template-columns: 0 1fr;
78 | grid-template-areas: "sidebar main";
79 | height: 100%;
80 | }
81 |
82 | .arco-menu-collapse-button {
83 | display: flex !important;
84 | top: 12px;
85 | }
86 | .trigger {
87 | display: block !important;
88 | }
89 | }
90 |
91 | @media screen and (max-width: 768px) {
92 | .background {
93 | position: absolute !important;
94 | top: 0 !important;
95 | left: 0 !important;
96 | width: 100% !important;
97 | }
98 | .page-layout {
99 | display: block !important;
100 | }
101 | .sidebar-drawer {
102 | width: 80% !important;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/utils/images.js:
--------------------------------------------------------------------------------
1 | export const extractImageSources = (htmlString) => {
2 | const doc = new DOMParser().parseFromString(htmlString, "text/html")
3 | const images = doc.querySelectorAll("img")
4 | return [...images].map((img) => img.getAttribute("src"))
5 | }
6 |
7 | const getWeiboFirstImage = (docs) => {
8 | const allImages = [...docs.querySelectorAll("img")]
9 | const filteredImages = allImages.filter((img) => {
10 | return !img.closest("a") && !(img.hasAttribute("alt") && /\[.+]/.test(img.getAttribute("alt")))
11 | })
12 | return filteredImages.length > 0 ? filteredImages[0] : null
13 | }
14 |
15 | const findMediaEnclosure = (enclosures) => {
16 | return enclosures?.find(
17 | (enclosure) =>
18 | enclosure.url !== "" &&
19 | (enclosure.mime_type.startsWith("video/") || enclosure.mime_type.startsWith("audio/")),
20 | )
21 | }
22 |
23 | const findImageEnclosure = (enclosures) => {
24 | return enclosures?.find(
25 | (enclosure) =>
26 | enclosure.mime_type.toLowerCase().startsWith("image/") ||
27 | /\.(jpg|jpeg|png|gif)$/i.test(enclosure.url),
28 | )
29 | }
30 |
31 | export const parseCoverImage = (entry) => {
32 | const doc = new DOMParser().parseFromString(entry.content, "text/html")
33 | const isWeiboFeed =
34 | entry.feed?.site_url && /https:\/\/weibo\.com\/\d+\//.test(entry.feed.site_url)
35 |
36 | // Get the first image
37 | const firstImage = isWeiboFeed ? getWeiboFirstImage(doc) : doc.querySelector("img")
38 |
39 | let coverSource = firstImage?.getAttribute("src")
40 | let isMedia = false
41 | let mediaPlayerEnclosure = null
42 |
43 | // If no cover image is found, try to get from other sources
44 | if (!coverSource) {
45 | // Check video poster
46 | const video = doc.querySelector("video")
47 | if (video) {
48 | coverSource = video.getAttribute("poster")
49 | isMedia = true
50 | } else {
51 | // Check media attachments
52 | mediaPlayerEnclosure = findMediaEnclosure(entry.enclosures)
53 | isMedia = !!mediaPlayerEnclosure
54 |
55 | // Check image attachments
56 | const imageEnclosure = findImageEnclosure(entry.enclosures)
57 | if (imageEnclosure) {
58 | coverSource = imageEnclosure.url
59 | }
60 | }
61 |
62 | // Check iframe
63 | if (!isMedia) {
64 | const iframe = doc.querySelector("iframe")
65 | const iframeHost = iframe?.getAttribute("src")?.split("/")[2]
66 | isMedia = !!iframeHost
67 | }
68 | }
69 |
70 | return { ...entry, coverSource, mediaPlayerEnclosure, isMedia }
71 | }
72 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
32 |
33 |
34 |
38 | ReactFlux
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/components/ui/FeedIcon.jsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from "react"
2 |
3 | import useFeedIcons from "@/hooks/useFeedIcons"
4 | import { updateFeedIcon } from "@/store/feedIconsState"
5 | import { getSecondHostname } from "@/utils/url"
6 |
7 | const DEFAULT_ICON_URL = "/default-feed-icon.png"
8 |
9 | const getFallbackIconURL = (feed) => {
10 | const hostname = getSecondHostname(feed.site_url) ?? getSecondHostname(feed.feed_url)
11 | return `https://icons.duckduckgo.com/ip3/${hostname}.ico`
12 | }
13 |
14 | const FeedIcon = ({ feed, className = "feed-icon" }) => {
15 | const { icon_id: iconId } = feed.icon
16 | const fallbackIconURL = getFallbackIconURL(feed)
17 |
18 | const [useFallback, setUseFallback] = useState(false)
19 | const [fallbackFailed, setFallbackFailed] = useState(false)
20 |
21 | const imgRef = useRef(null)
22 |
23 | const fetchedIcon = useFeedIcons(iconId, feed)
24 | const fetchedIconURL = fetchedIcon?.url
25 |
26 | const iconURL = (() => {
27 | if (fallbackFailed) {
28 | return DEFAULT_ICON_URL
29 | }
30 | if (fetchedIconURL && !useFallback) {
31 | return fetchedIconURL
32 | }
33 | if (iconId === 0 || useFallback) {
34 | return fallbackIconURL
35 | }
36 | return DEFAULT_ICON_URL
37 | })()
38 |
39 | const handleImageLoad = () => {
40 | if (imgRef.current) {
41 | const { naturalWidth, naturalHeight } = imgRef.current
42 | if (naturalWidth > 200 && naturalHeight > 200 && fetchedIcon.width === null) {
43 | updateFeedIcon(iconId, { width: naturalWidth, height: naturalHeight })
44 | }
45 | if ((naturalWidth !== naturalHeight || naturalWidth === 0) && !useFallback) {
46 | setUseFallback(true)
47 | }
48 | }
49 | }
50 |
51 | const handleError = () => {
52 | if (iconURL === fallbackIconURL && !fallbackFailed) {
53 | setFallbackFailed(true)
54 | return
55 | }
56 |
57 | if (!fallbackFailed && !useFallback) {
58 | setUseFallback(true)
59 | }
60 | }
61 |
62 | if (fallbackFailed) {
63 | return (
64 |
70 | )
71 | }
72 |
73 | return (
74 |
86 | )
87 | }
88 |
89 | export default FeedIcon
90 |
--------------------------------------------------------------------------------
/.github/workflows/dependabot-auto-merge.yml:
--------------------------------------------------------------------------------
1 | name: Dependabot auto-merge
2 | on: pull_request
3 |
4 | permissions:
5 | contents: write
6 | pull-requests: write
7 |
8 | jobs:
9 | dependabot:
10 | runs-on: ubuntu-latest
11 | if: github.event.pull_request.user.login == 'dependabot[bot]'
12 | steps:
13 | - name: Dependabot metadata
14 | id: metadata
15 | uses: dependabot/fetch-metadata@v2
16 | with:
17 | github-token: "${{ secrets.GITHUB_TOKEN }}"
18 |
19 | # Checkout PR and validate pnpm lock file
20 | - name: Checkout PR
21 | uses: actions/checkout@v6
22 | with:
23 | token: ${{ secrets.GITHUB_TOKEN }}
24 |
25 | - name: Setup pnpm
26 | uses: pnpm/action-setup@v4
27 | with:
28 | version: latest
29 |
30 | - name: Setup Node.js
31 | uses: actions/setup-node@v6
32 | with:
33 | node-version: "22"
34 | cache: "pnpm"
35 |
36 | - name: Validate pnpm-lock.yaml
37 | id: validate_lock
38 | run: |
39 | echo "Validating pnpm-lock.yaml file..."
40 | if pnpm install --frozen-lockfile; then
41 | echo "lock_file_valid=true" >> $GITHUB_OUTPUT
42 | echo "✅ pnpm-lock.yaml validation passed"
43 | else
44 | echo "lock_file_valid=false" >> $GITHUB_OUTPUT
45 | echo "❌ pnpm-lock.yaml validation failed"
46 | fi
47 |
48 | # Auto-merge only if lock file is valid and meets criteria
49 | - name: Enable auto-merge for Dependabot PRs
50 | if: |
51 | steps.validate_lock.outputs.lock_file_valid == 'true' &&
52 | (steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
53 | steps.metadata.outputs.dependency-type == 'direct:development')
54 | run: gh pr merge --auto --squash "$PR_URL"
55 | env:
56 | PR_URL: ${{ github.event.pull_request.html_url }}
57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58 |
59 | # Add comment if lock file validation fails
60 | - name: Comment on invalid lock file
61 | if: steps.validate_lock.outputs.lock_file_valid == 'false'
62 | run: |
63 | gh pr comment ${{ github.event.pull_request.number }} --body "❌ **Automatic merge failed**
64 |
65 | Validation of pnpm-lock.yaml failed. Possible reasons:
66 | - Dependency conflicts with other PRs
67 | - Corrupted or inconsistent lock file
68 |
69 | Please manually check and fix this PR, or wait for other PRs to merge first and then retry.
70 |
71 | You can try running \`@dependabot recreate\` to regenerate this PR."
72 | env:
73 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reactflux",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "vite build",
8 | "preview": "pnpm run build && vite preview",
9 | "format": "prettier --write .",
10 | "format:check": "prettier --check .",
11 | "lint": "eslint",
12 | "lint:fix": "eslint --fix",
13 | "prepare": "simple-git-hooks",
14 | "prebuild": "node src/scripts/version-info.js",
15 | "update-fonts": "node scripts/update-fonts.js"
16 | },
17 | "dependencies": {
18 | "@arco-design/color": "^0.4.0",
19 | "@arco-design/web-react": "^2.66.8",
20 | "@nanostores/persistent": "^1.2.0",
21 | "@nanostores/react": "^1.0.0",
22 | "canvas-confetti": "^1.9.4",
23 | "classnames": "^2.5.1",
24 | "dayjs": "^1.11.19",
25 | "framer-motion": "^12.23.26",
26 | "highlight.js": "^11.11.1",
27 | "hls.js": "^1.6.15",
28 | "html-react-parser": "^5.2.10",
29 | "littlefoot": "^4.1.3",
30 | "lodash-es": "^4.17.21",
31 | "nanostores": "^1.1.0",
32 | "node-polyglot": "^2.6.0",
33 | "ofetch": "^1.5.1",
34 | "plyr": "^3.8.3",
35 | "react": "^18.3.1",
36 | "react-dom": "^18.3.1",
37 | "react-hotkeys-hook": "^5.2.1",
38 | "react-intersection-observer": "^10.0.0",
39 | "react-router": "^7.10.1",
40 | "react-swipeable": "^7.0.2",
41 | "react-syntax-highlighter": "^16.1.0",
42 | "simplebar-react": "^3.3.2",
43 | "validator": "^13.15.23",
44 | "virtua": "^0.48.2",
45 | "yet-another-react-lightbox": "^3.25.0"
46 | },
47 | "devDependencies": {
48 | "@eslint/js": "^9.39.1",
49 | "@types/node": "^25.0.1",
50 | "@types/react": "^18.3.27",
51 | "@types/react-dom": "^18.3.6",
52 | "@vitejs/plugin-react": "^5.1.2",
53 | "babel-plugin-react-compiler": "19.1.0-rc.3",
54 | "depcheck": "^1.4.7",
55 | "eslint": "^9.39.1",
56 | "eslint-config-prettier": "^10.1.8",
57 | "eslint-plugin-import-x": "^4.16.1",
58 | "eslint-plugin-promise": "^7.2.1",
59 | "eslint-plugin-react": "^7.37.5",
60 | "eslint-plugin-react-compiler": "19.1.0-rc.2",
61 | "eslint-plugin-react-hooks": "^7.0.1",
62 | "eslint-plugin-react-refresh": "^0.4.24",
63 | "eslint-plugin-unicorn": "^61.0.2",
64 | "globals": "^16.5.0",
65 | "lint-staged": "^16.2.7",
66 | "prettier": "^3.7.4",
67 | "react-compiler-runtime": "19.1.0-rc.3",
68 | "rollup-plugin-visualizer": "^6.0.5",
69 | "simple-git-hooks": "2.13.1",
70 | "vite": "^7.2.7",
71 | "vite-plugin-pwa": "^1.2.0",
72 | "workbox-window": "^7.4.0"
73 | },
74 | "simple-git-hooks": {
75 | "pre-commit": "pnpm exec lint-staged"
76 | },
77 | "lint-staged": {
78 | "*.{js,ts,mjs,jsx,tsx}": [
79 | "eslint --fix",
80 | "prettier --write"
81 | ],
82 | "*.{json,jsonc,css,md}": [
83 | "prettier --write"
84 | ]
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/Settings/CategoryList.jsx:
--------------------------------------------------------------------------------
1 | import { Form, Input, Tag } from "@arco-design/web-react"
2 | import { IconPlus } from "@arco-design/web-react/icon"
3 | import { useStore } from "@nanostores/react"
4 | import { useState } from "react"
5 |
6 | import EditCategoryModal from "@/components/ui/EditCategoryModal"
7 | import useCategoryOperations from "@/hooks/useCategoryOperations"
8 | import { categoriesState } from "@/store/dataState"
9 | import "./CategoryList.css"
10 |
11 | const CategoryList = () => {
12 | const categories = useStore(categoriesState)
13 |
14 | const [categoryForm] = Form.useForm()
15 | const [categoryModalVisible, setCategoryModalVisible] = useState(false)
16 | const [inputAddValue, setInputAddValue] = useState("")
17 | const [selectedCategory, setSelectedCategory] = useState({})
18 | const [showAddInput, setShowAddInput] = useState(false)
19 |
20 | const { addNewCategory, handleDeleteCategory } = useCategoryOperations(false)
21 |
22 | const handleAddNewCategory = async () => {
23 | await addNewCategory(inputAddValue)
24 | setInputAddValue("")
25 | setShowAddInput(false)
26 | }
27 |
28 | return (
29 | <>
30 |
31 | {categories.map((category) => (
32 | {
38 | setSelectedCategory(category)
39 | setCategoryModalVisible(true)
40 | categoryForm.setFieldsValue({
41 | title: category.title,
42 | })
43 | }}
44 | onClose={async (event) => {
45 | event.stopPropagation()
46 | await handleDeleteCategory(category, false)
47 | }}
48 | >
49 | {category.title}
50 |
51 | ))}
52 | {showAddInput ? (
53 |
62 | ) : (
63 | }
66 | size="medium"
67 | tabIndex={0}
68 | onClick={() => setShowAddInput(true)}
69 | />
70 | )}
71 |
72 | {selectedCategory && (
73 |
80 | )}
81 | >
82 | )
83 | }
84 |
85 | export default CategoryList
86 |
--------------------------------------------------------------------------------
/src/hooks/useContentHotkeys.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { useHotkeys } from "react-hotkeys-hook"
3 |
4 | import useEntryActions from "@/hooks/useEntryActions"
5 | import useKeyHandlers from "@/hooks/useKeyHandlers"
6 | import { contentState } from "@/store/contentState"
7 | import { duplicateHotkeysState, hotkeysState } from "@/store/hotkeysState"
8 |
9 | const useContentHotkeys = ({ handleRefreshArticleList }) => {
10 | const { activeContent } = useStore(contentState)
11 | const duplicateHotkeys = useStore(duplicateHotkeysState)
12 | const hotkeys = useStore(hotkeysState)
13 |
14 | const {
15 | exitDetailView,
16 | fetchOriginalArticle,
17 | navigateToNextArticle,
18 | navigateToNextUnreadArticle,
19 | navigateToPreviousArticle,
20 | navigateToPreviousUnreadArticle,
21 | openLinkExternally,
22 | openPhotoSlider,
23 | saveToThirdPartyServices,
24 | showHotkeysSettings,
25 | toggleReadStatus,
26 | toggleStarStatus,
27 | } = useKeyHandlers()
28 |
29 | const {
30 | handleFetchContent,
31 | handleSaveToThirdPartyServices,
32 | handleToggleStarred,
33 | handleToggleStatus,
34 | } = useEntryActions()
35 |
36 | const removeConflictingKeys = (keys) => keys.filter((key) => !duplicateHotkeys.includes(key))
37 |
38 | useHotkeys(removeConflictingKeys(hotkeys.exitDetailView), exitDetailView)
39 |
40 | useHotkeys(removeConflictingKeys(hotkeys.fetchOriginalArticle), () =>
41 | fetchOriginalArticle(handleFetchContent),
42 | )
43 |
44 | useHotkeys(removeConflictingKeys(hotkeys.navigateToNextArticle), () => navigateToNextArticle())
45 |
46 | useHotkeys(removeConflictingKeys(hotkeys.navigateToNextUnreadArticle), () =>
47 | navigateToNextUnreadArticle(),
48 | )
49 |
50 | useHotkeys(removeConflictingKeys(hotkeys.navigateToPreviousArticle), () =>
51 | navigateToPreviousArticle(),
52 | )
53 |
54 | useHotkeys(removeConflictingKeys(hotkeys.navigateToPreviousUnreadArticle), () =>
55 | navigateToPreviousUnreadArticle(),
56 | )
57 |
58 | useHotkeys(removeConflictingKeys(hotkeys.openLinkExternally), openLinkExternally)
59 |
60 | useHotkeys(removeConflictingKeys(hotkeys.openPhotoSlider), openPhotoSlider)
61 |
62 | useHotkeys(removeConflictingKeys(hotkeys.refreshArticleList), handleRefreshArticleList)
63 |
64 | useHotkeys(removeConflictingKeys(hotkeys.saveToThirdPartyServices), () =>
65 | saveToThirdPartyServices(() => handleSaveToThirdPartyServices(activeContent)),
66 | )
67 |
68 | useHotkeys(removeConflictingKeys(hotkeys.showHotkeysSettings), showHotkeysSettings, {
69 | useKey: true,
70 | })
71 |
72 | useHotkeys(removeConflictingKeys(hotkeys.toggleReadStatus), () =>
73 | toggleReadStatus(() => handleToggleStatus(activeContent)),
74 | )
75 |
76 | useHotkeys(removeConflictingKeys(hotkeys.toggleStarStatus), () =>
77 | toggleStarStatus(() => handleToggleStarred(activeContent)),
78 | )
79 | }
80 |
81 | export default useContentHotkeys
82 |
--------------------------------------------------------------------------------
/src/utils/date.js:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs"
2 | import duration from "dayjs/plugin/duration"
3 | import localizedFormat from "dayjs/plugin/localizedFormat"
4 | import relativeTime from "dayjs/plugin/relativeTime"
5 | import utc from "dayjs/plugin/utc"
6 |
7 | import { polyglotState } from "@/hooks/useLanguage"
8 |
9 | dayjs.extend(duration)
10 | dayjs.extend(localizedFormat)
11 | dayjs.extend(relativeTime)
12 | dayjs.extend(utc)
13 |
14 | export const checkIsInLast24Hours = (dateString) => {
15 | const targetDate = /^\d+$/.test(dateString) ? dayjs(Number(dateString) * 1000) : dayjs(dateString)
16 | return targetDate.isAfter(dayjs().subtract(24, "hour"))
17 | }
18 |
19 | export const get24HoursAgoTimestamp = () => dayjs().subtract(24, "hour").unix()
20 |
21 | export const getTimestamp = (dateString) => dayjs(dateString).unix()
22 |
23 | export const getStartOfToday = () => dayjs().startOf("day")
24 |
25 | export const getDayEndTimestamp = (dateString) => dayjs(dateString).endOf("day").unix()
26 |
27 | export const getUTCDate = () => dayjs().utc().format("YYYY-MM-DDTHH:mm:ss.SSSSSSZ")
28 |
29 | export const generateReadableDate = (dateString) => dayjs(dateString).format("dddd · LL · LT")
30 |
31 | export const generateRelativeTime = (dateString, showDetailed) => {
32 | const { polyglot } = polyglotState.get()
33 |
34 | if (!showDetailed) {
35 | const now = dayjs()
36 | const target = dayjs(dateString)
37 | const relativeTime = target.from(now)
38 | const diffInSeconds = dayjs.duration(now.diff(target)).asSeconds()
39 | if (diffInSeconds >= 0 && diffInSeconds < 60) {
40 | return polyglot.t("date.just_now")
41 | }
42 | return relativeTime
43 | }
44 |
45 | const now = dayjs()
46 | const target = dayjs(dateString)
47 | const diff = target.diff(now)
48 | const diffDuration = dayjs.duration(Math.abs(diff))
49 |
50 | const years = diffDuration.years()
51 | const months = diffDuration.months()
52 | const days = diffDuration.days()
53 | const hours = diffDuration.hours()
54 | const minutes = diffDuration.minutes()
55 |
56 | const timeUnits = []
57 | if (years > 0) {
58 | timeUnits.push(polyglot.t("date.years", years))
59 | }
60 | if (months > 0) {
61 | timeUnits.push(polyglot.t("date.months", months))
62 | }
63 | if (days > 0) {
64 | timeUnits.push(polyglot.t("date.days", days))
65 | }
66 | if (hours > 0) {
67 | timeUnits.push(polyglot.t("date.hours", hours))
68 | }
69 | if (minutes > 0) {
70 | timeUnits.push(polyglot.t("date.minutes", minutes))
71 | }
72 |
73 | if (timeUnits.length === 0) {
74 | return polyglot.t("date.just_now")
75 | }
76 |
77 | const relativeTime = timeUnits.join(" ")
78 | return diff > 0
79 | ? polyglot.t("date.in_time", { time: relativeTime })
80 | : polyglot.t("date.time_ago", { time: relativeTime })
81 | }
82 |
83 | export const generateReadingTime = (time) => {
84 | const { polyglot } = polyglotState.get()
85 |
86 | const minuteStr = polyglot.t("date.minutes", time)
87 | return polyglot.t("article_card.reading_time", { time: minuteStr })
88 | }
89 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleTOC.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Dropdown, Input, Menu, Typography } from "@arco-design/web-react"
2 | import { IconUnorderedList } from "@arco-design/web-react/icon"
3 | import { useStore } from "@nanostores/react"
4 | import { memo, useState } from "react"
5 | import SimpleBar from "simplebar-react"
6 |
7 | import CustomTooltip from "@/components/ui/CustomTooltip"
8 | import { polyglotState } from "@/hooks/useLanguage"
9 | import { articleHeadingsState } from "@/store/contentState"
10 | import { scrollToHeading } from "@/utils/dom"
11 | import { includesIgnoreCase } from "@/utils/filter"
12 | import "./ArticleTOC.css"
13 |
14 | const ArticleTOC = () => {
15 | const headings = useStore(articleHeadingsState)
16 | const { polyglot } = useStore(polyglotState)
17 |
18 | const [dropdownVisible, setDropdownVisible] = useState(false)
19 | const [filterValue, setFilterValue] = useState("")
20 |
21 | const filteredHeadings = headings.filter((heading) =>
22 | includesIgnoreCase(heading.text, filterValue),
23 | )
24 |
25 | const handleFilterChange = (value) => {
26 | setFilterValue(value)
27 | }
28 |
29 | const handleHeadingClick = (heading) => {
30 | scrollToHeading(heading)
31 | setDropdownVisible(false)
32 | }
33 |
34 | if (headings.length === 0) {
35 | return null
36 | }
37 |
38 | return (
39 |
46 |
47 |
54 |
55 |
56 |
71 |
72 |
73 | }
74 | onVisibleChange={setDropdownVisible}
75 | >
76 |
77 | } shape="circle" />
78 |
79 |
80 | )
81 | }
82 |
83 | export default memo(ArticleTOC)
84 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: "🐛 Bug Report"
2 | description: "Create a report to help us improve ReactFlux"
3 | title: "[Bug] "
4 | labels: ["bug"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report!
10 |
11 | - type: textarea
12 | id: bug-description
13 | attributes:
14 | label: Describe the Bug
15 | description: A clear and concise description of what the bug is.
16 | validations:
17 | required: true
18 |
19 | - type: textarea
20 | id: reproduction
21 | attributes:
22 | label: Steps to Reproduce
23 | description: How can we reproduce this issue?
24 | placeholder: |
25 | 1. Go to '...'
26 | 2. Click on '...'
27 | 3. See error
28 | validations:
29 | required: true
30 |
31 | - type: textarea
32 | id: expected
33 | attributes:
34 | label: Expected Behavior
35 | description: A clear and concise description of what you expected to happen.
36 | validations:
37 | required: true
38 |
39 | - type: textarea
40 | id: screenshots
41 | attributes:
42 | label: Screenshots / Videos
43 | description: If applicable, add screenshots or videos to help explain your problem.
44 | validations:
45 | required: false
46 |
47 | - type: input
48 | id: reactflux-version
49 | attributes:
50 | label: ReactFlux Version
51 | placeholder: "e.g. v2024.10.31 or 26bb210"
52 | validations:
53 | required: true
54 |
55 | - type: input
56 | id: miniflux-version
57 | attributes:
58 | label: Miniflux Version
59 | placeholder: "e.g. v2.2.2"
60 | validations:
61 | required: true
62 |
63 | - type: input
64 | id: browser
65 | attributes:
66 | label: Browser
67 | placeholder: "e.g. Chrome 130.0.6723.92"
68 | validations:
69 | required: true
70 |
71 | - type: input
72 | id: os
73 | attributes:
74 | label: Operating System
75 | placeholder: "e.g. Windows 11 24H2, iOS 18.1"
76 | validations:
77 | required: true
78 |
79 | - type: input
80 | id: device
81 | attributes:
82 | label: Device
83 | placeholder: "e.g. Desktop, iPhone 16"
84 | validations:
85 | required: true
86 |
87 | - type: textarea
88 | id: console
89 | attributes:
90 | label: Console Errors
91 | description: If applicable, please provide any relevant console errors
92 | render: shell
93 | validations:
94 | required: false
95 |
96 | - type: textarea
97 | id: additional
98 | attributes:
99 | label: Additional Context
100 | description: |
101 | Please provide any additional context that might be helpful:
102 | - Are you using any specific settings? (e.g. dark mode, custom theme)
103 | - Does this happen with specific feeds or articles?
104 | - Any other relevant information
105 | validations:
106 | required: false
107 |
--------------------------------------------------------------------------------
/src/components/Article/CodeBlock.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Message, Select } from "@arco-design/web-react"
2 | import { IconCopy } from "@arco-design/web-react/icon"
3 | import { useStore } from "@nanostores/react"
4 | import hljs from "highlight.js"
5 | import { useCallback, useEffect, useState } from "react"
6 | import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"
7 |
8 | import CustomTooltip from "@/components/ui/CustomTooltip"
9 | import { polyglotState } from "@/hooks/useLanguage"
10 | import { ANIMATION_DURATION_MS } from "@/utils/constants"
11 | import { LANGUAGE_DISPLAY_NAMES, SUPPORTED_LANGUAGES, SyntaxHighlighter } from "@/utils/highlighter"
12 | import "./CodeBlock.css"
13 |
14 | const CodeBlock = ({ children }) => {
15 | const { polyglot } = useStore(polyglotState)
16 |
17 | const [language, setLanguage] = useState("plaintext")
18 |
19 | const copyToClipboard = useCallback(() => {
20 | navigator.clipboard
21 | .writeText(children.trim())
22 | .then(() => Message.success(polyglot.t("actions.copied")))
23 | .catch((error) => {
24 | console.error(error)
25 | Message.error(polyglot.t("actions.copy_failed"))
26 | })
27 | }, [children, polyglot])
28 |
29 | const code = children.trim()
30 |
31 | useEffect(() => {
32 | const timeoutId = setTimeout(() => {
33 | const detectedLanguage = hljs.highlightAuto(children).language
34 | if (SUPPORTED_LANGUAGES.includes(detectedLanguage)) {
35 | setLanguage(detectedLanguage)
36 | } else {
37 | console.info("detectedLanguage not supported:", detectedLanguage)
38 | }
39 | }, ANIMATION_DURATION_MS)
40 |
41 | return () => clearTimeout(timeoutId)
42 | }, [children])
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
56 | {code}
57 |
58 |
59 | )
60 | }
61 |
62 | const LanguageSelector = ({ language, setLanguage }) => (
63 |
80 | )
81 |
82 | const CopyButton = ({ onClick }) => {
83 | const { polyglot } = useStore(polyglotState)
84 |
85 | return (
86 |
87 | } onClick={onClick} />
88 |
89 | )
90 | }
91 |
92 | export default CodeBlock
93 |
--------------------------------------------------------------------------------
/src/components/Settings/EditableTag.jsx:
--------------------------------------------------------------------------------
1 | import { Input, Tag } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 | import { useCallback, useEffect, useState } from "react"
4 |
5 | import { duplicateHotkeysState } from "@/store/hotkeysState"
6 |
7 | const EditableTag = ({ value, onChange, onRemove, editOnMount = false }) => {
8 | const duplicateHotkeys = useStore(duplicateHotkeysState)
9 |
10 | const [isEditing, setIsEditing] = useState(editOnMount)
11 |
12 | const handleEdit = () => {
13 | if (value === "") {
14 | onRemove()
15 | }
16 | setIsEditing(false)
17 | }
18 |
19 | const handleKeyDown = useCallback(
20 | (event) => {
21 | if (isEditing) {
22 | event.preventDefault()
23 |
24 | const { key, ctrlKey, shiftKey, altKey, metaKey } = event
25 |
26 | if (["Control", "Shift", "Alt", "Meta", "CapsLock"].includes(key)) {
27 | return
28 | }
29 |
30 | let keyName = key
31 | switch (key) {
32 | case "ArrowLeft":
33 | case "ArrowRight":
34 | case "ArrowUp":
35 | case "ArrowDown": {
36 | keyName = key.replace("Arrow", "").toLowerCase()
37 | break
38 | }
39 | case " ": {
40 | keyName = "space"
41 | break
42 | }
43 | }
44 |
45 | const modifiers = []
46 | if (ctrlKey) {
47 | modifiers.push("ctrl")
48 | }
49 | if (shiftKey) {
50 | modifiers.push("shift")
51 | }
52 | if (altKey) {
53 | modifiers.push("alt")
54 | }
55 | if (metaKey) {
56 | modifiers.push("meta")
57 | }
58 |
59 | const newValue = modifiers.length > 0 ? `${modifiers.join("+")}+${keyName}` : keyName
60 | onChange(newValue)
61 | setIsEditing(false)
62 | }
63 | },
64 | [isEditing, onChange],
65 | )
66 |
67 | useEffect(() => {
68 | globalThis.addEventListener("keydown", handleKeyDown)
69 | return () => {
70 | globalThis.removeEventListener("keydown", handleKeyDown)
71 | }
72 | }, [handleKeyDown])
73 |
74 | return isEditing ? (
75 |
85 | ) : (
86 | setIsEditing(true)}
95 | onClose={(event) => {
96 | event.stopPropagation()
97 | onRemove()
98 | }}
99 | >
100 | {value}
101 |
102 | )
103 | }
104 |
105 | export default EditableTag
106 |
--------------------------------------------------------------------------------
/src/hooks/useArticleList.js:
--------------------------------------------------------------------------------
1 | import { useStore } from "@nanostores/react"
2 | import { useEffect, useRef } from "react"
3 |
4 | import {
5 | contentState,
6 | setEntriesWithDeduplication,
7 | setIsArticleListReady,
8 | setLoadMoreVisible,
9 | setTotal,
10 | } from "@/store/contentState"
11 | import {
12 | dataState,
13 | setHistoryCount,
14 | setStarredCount,
15 | setUnreadInfo,
16 | setUnreadStarredCount,
17 | setUnreadTodayCount,
18 | } from "@/store/dataState"
19 | import { settingsState } from "@/store/settingsState"
20 | import { parseCoverImage } from "@/utils/images"
21 |
22 | const handleResponses = (response) => {
23 | if (response?.total >= 0) {
24 | const articles = response.entries.map((entry) => parseCoverImage(entry))
25 | setEntriesWithDeduplication(articles)
26 | setTotal(response.total)
27 | setLoadMoreVisible(articles.length < response.total)
28 | }
29 | }
30 |
31 | const useArticleList = (info, getEntries) => {
32 | const { filterDate } = useStore(contentState)
33 | const { isAppDataReady } = useStore(dataState)
34 | const { showStatus } = useStore(settingsState)
35 |
36 | const isLoading = useRef(false)
37 |
38 | const fetchArticleList = async (getEntries) => {
39 | if (isLoading.current) {
40 | return
41 | }
42 |
43 | isLoading.current = true
44 | setIsArticleListReady(false)
45 |
46 | try {
47 | let response
48 |
49 | switch (showStatus) {
50 | case "starred": {
51 | response = await getEntries(null, true)
52 | break
53 | }
54 | case "unread": {
55 | response = await getEntries("unread")
56 | break
57 | }
58 | default: {
59 | response = await getEntries()
60 | break
61 | }
62 | }
63 |
64 | if (!filterDate) {
65 | switch (info.from) {
66 | case "feed": {
67 | if (showStatus === "unread") {
68 | setUnreadInfo((prev) => ({
69 | ...prev,
70 | [Number(info.id)]: response.total,
71 | }))
72 | }
73 | break
74 | }
75 | case "history": {
76 | setHistoryCount(response.total)
77 | break
78 | }
79 | case "starred": {
80 | if (showStatus === "unread") {
81 | setUnreadStarredCount(response.total)
82 | } else {
83 | setStarredCount(response.total)
84 | }
85 | break
86 | }
87 | case "today": {
88 | if (showStatus === "unread") {
89 | setUnreadTodayCount(response.total)
90 | }
91 | break
92 | }
93 | }
94 | }
95 |
96 | handleResponses(response)
97 | } catch (error) {
98 | console.error("Error fetching articles:", error)
99 | } finally {
100 | isLoading.current = false
101 | setIsArticleListReady(true)
102 | }
103 | }
104 |
105 | useEffect(() => {
106 | if (isAppDataReady) {
107 | fetchArticleList(getEntries)
108 | }
109 | }, [isAppDataReady])
110 |
111 | return { fetchArticleList }
112 | }
113 |
114 | export default useArticleList
115 |
--------------------------------------------------------------------------------
/src/components/Content/ContentContext.jsx:
--------------------------------------------------------------------------------
1 | import { Message } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 | import { createContext, useCallback, useMemo, useRef } from "react"
4 | import { useLocation, useNavigate } from "react-router"
5 |
6 | import { updateEntriesStatus } from "@/apis"
7 | import useEntryActions from "@/hooks/useEntryActions"
8 | import { polyglotState } from "@/hooks/useLanguage"
9 | import { setActiveContent, setIsArticleLoading } from "@/store/contentState"
10 | import { settingsState } from "@/store/settingsState"
11 | import { ANIMATION_DURATION_MS } from "@/utils/constants"
12 | import { buildEntryDetailPath, extractBasePath, isEntryDetailPath } from "@/utils/url"
13 |
14 | const Context = createContext()
15 |
16 | export const ContextProvider = ({ children }) => {
17 | const { polyglot } = useStore(polyglotState)
18 | const { markReadBy } = useStore(settingsState)
19 |
20 | const entryDetailRef = useRef(null)
21 | const entryListRef = useRef(null)
22 | const navigate = useNavigate()
23 | const location = useLocation()
24 |
25 | const { handleEntryStatusUpdate } = useEntryActions()
26 |
27 | const closeActiveContent = useCallback(() => {
28 | setActiveContent(null)
29 |
30 | const currentPath = location.pathname
31 | const basePath = extractBasePath(currentPath)
32 |
33 | if (isEntryDetailPath(currentPath) && basePath) {
34 | navigate(basePath)
35 | }
36 | }, [location.pathname, navigate])
37 |
38 | const handleEntryClick = useCallback(
39 | async (entry) => {
40 | setIsArticleLoading(true)
41 |
42 | const shouldAutoMarkAsRead = markReadBy === "view"
43 | const updatedEntry = shouldAutoMarkAsRead ? { ...entry, status: "read" } : { ...entry }
44 |
45 | setActiveContent(updatedEntry)
46 |
47 | const currentPath = location.pathname
48 | const basePath = extractBasePath(currentPath)
49 | const entryDetailPath = buildEntryDetailPath(basePath, entry.id)
50 |
51 | navigate(entryDetailPath)
52 |
53 | setTimeout(() => {
54 | const articleContent = entryDetailRef.current
55 | if (articleContent) {
56 | const contentWrapper = articleContent.querySelector(".simplebar-content-wrapper")
57 | if (contentWrapper) {
58 | contentWrapper.scroll({ top: 0 })
59 | }
60 | articleContent.focus()
61 | }
62 |
63 | setIsArticleLoading(false)
64 | if (shouldAutoMarkAsRead && entry.status === "unread") {
65 | handleEntryStatusUpdate(entry, "read")
66 | updateEntriesStatus([entry.id], "read").catch(() => {
67 | Message.error(polyglot.t("content.mark_as_read_error"))
68 | setActiveContent({ ...entry, status: "unread" })
69 | handleEntryStatusUpdate(entry, "unread")
70 | })
71 | }
72 | }, ANIMATION_DURATION_MS)
73 | },
74 | [polyglot, handleEntryStatusUpdate, markReadBy, location.pathname, navigate],
75 | )
76 |
77 | const value = useMemo(
78 | () => ({
79 | entryDetailRef,
80 | entryListRef,
81 | handleEntryClick,
82 | setActiveContent,
83 | closeActiveContent,
84 | }),
85 | [handleEntryClick, closeActiveContent],
86 | )
87 |
88 | return {children}
89 | }
90 |
91 | export const ContentContext = Context
92 |
--------------------------------------------------------------------------------
/src/components/Settings/SettingsTabs.jsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "@arco-design/web-react"
2 | import {
3 | IconCommand,
4 | IconFile,
5 | IconFolder,
6 | IconSkin,
7 | IconStorage,
8 | } from "@arco-design/web-react/icon"
9 | import { useStore } from "@nanostores/react"
10 | import SimpleBar from "simplebar-react"
11 |
12 | import Appearance from "./Appearance"
13 | import CategoryList from "./CategoryList"
14 | import FeedList from "./FeedList"
15 | import General from "./General"
16 | import Hotkeys from "./Hotkeys"
17 |
18 | import { polyglotState } from "@/hooks/useLanguage"
19 | import useScreenWidth from "@/hooks/useScreenWidth"
20 |
21 | import "./SettingsTabs.css"
22 |
23 | const CustomTabTitle = ({ icon, title }) => (
24 |
31 | {icon}
32 |
{title}
33 |
34 | )
35 |
36 | const SettingsTabs = ({ activeTab, onTabChange }) => {
37 | const { polyglot } = useStore(polyglotState)
38 | const { isBelowMedium } = useScreenWidth()
39 |
40 | return (
41 |
49 |
56 | }
61 | title={polyglot.t("settings.feeds")}
62 | />
63 | }
64 | >
65 |
66 |
67 | }
72 | title={polyglot.t("settings.categories")}
73 | />
74 | }
75 | >
76 |
77 |
78 | }
83 | title={polyglot.t("settings.general")}
84 | />
85 | }
86 | >
87 |
88 |
89 | }
94 | title={polyglot.t("settings.appearance")}
95 | />
96 | }
97 | >
98 |
99 |
100 | {!isBelowMedium && (
101 | }
106 | title={polyglot.t("settings.hotkeys")}
107 | />
108 | }
109 | >
110 |
111 |
112 | )}
113 |
114 |
115 | )
116 | }
117 |
118 | export default SettingsTabs
119 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { Button, ConfigProvider, Layout, Notification } from "@arco-design/web-react"
2 | import deDE from "@arco-design/web-react/es/locale/de-DE"
3 | import enUS from "@arco-design/web-react/es/locale/en-US"
4 | import esES from "@arco-design/web-react/es/locale/es-ES"
5 | import frFR from "@arco-design/web-react/es/locale/fr-FR"
6 | import zhCN from "@arco-design/web-react/es/locale/zh-CN"
7 | import { useStore } from "@nanostores/react"
8 | import { useEffect } from "react"
9 |
10 | import "./App.css"
11 | import Main from "./components/Main/Main"
12 | import Sidebar from "./components/Sidebar/Sidebar"
13 | import useFeedIconsSync from "./hooks/useFeedIconsSync"
14 | import useLanguage, { polyglotState } from "./hooks/useLanguage"
15 | import useScreenWidth from "./hooks/useScreenWidth"
16 | import useTheme from "./hooks/useTheme"
17 | import useVersionCheck from "./hooks/useVersionCheck"
18 | import { settingsState } from "./store/settingsState"
19 | import { GITHUB_REPO_PATH } from "./utils/constants"
20 | import hideSpinner from "./utils/loading"
21 |
22 | const localMap = {
23 | "de-DE": deDE,
24 | "es-ES": esES,
25 | "fr-FR": frFR,
26 | "zh-CN": zhCN,
27 | }
28 |
29 | const getLocale = (language) => localMap[language] || enUS
30 |
31 | const App = () => {
32 | useLanguage()
33 | useTheme()
34 | useFeedIconsSync()
35 |
36 | const { hasUpdate, dismissUpdate } = useVersionCheck()
37 |
38 | const { isBelowLarge } = useScreenWidth()
39 |
40 | const { polyglot } = useStore(polyglotState)
41 | const { language } = useStore(settingsState)
42 | const locale = getLocale(language)
43 |
44 | useEffect(() => {
45 | hideSpinner()
46 | }, [])
47 |
48 | useEffect(() => {
49 | if (hasUpdate) {
50 | const id = "new-version-available"
51 | Notification.info({
52 | id,
53 | title: polyglot.t("app.new_version_available"),
54 | closable: false,
55 | content: polyglot.t("app.new_version_available_description"),
56 | duration: 0,
57 | btn: (
58 |
59 |
70 |
80 |
81 | ),
82 | })
83 | }
84 | }, [dismissUpdate, hasUpdate, polyglot])
85 |
86 | return (
87 | polyglot && (
88 |
89 |
90 | {isBelowLarge ? null : (
91 |
98 |
99 |
100 | )}
101 |
102 |
103 |
104 | )
105 | )
106 | }
107 |
108 | export default App
109 |
--------------------------------------------------------------------------------
/docs/README.zh-CN.md:
--------------------------------------------------------------------------------
1 | # ReactFlux
2 |
3 | 阅读其他语言版本: [Deutsch](README.de-DE.md), [English](../README.md), [Español](README.es-ES.md), [Français](README.fr-FR.md)
4 |
5 | ## 概述
6 |
7 | ReactFlux 是 [Miniflux](https://github.com/miniflux/v2) 的第三方 Web 前端,旨在提供更加友好的阅读体验。
8 |
9 | 支持的 Miniflux 版本:2.1.4 及更高版本。
10 |
11 | 主要特性包括:
12 |
13 | - 现代化的界面设计
14 | - 响应式布局,支持手势操作
15 | - 支持黑暗模式和自定义主题
16 | - 可自定义的阅读体验:
17 | - 字体样式和大小设置
18 | - 文章宽度调整
19 | - 标题对齐选项
20 | - 带缩放和幻灯片功能的图片查看器
21 | - 脚注增强
22 | - 代码语法高亮
23 | - 预计阅读时间
24 | - 文章和订阅源管理:
25 | - 类 Google 搜索语法
26 | - 按阅读状态、发布日期、标题、内容或作者过滤文章
27 | - 订阅源批量操作
28 | - 全文获取支持
29 | - 按 hash、标题或 URL 去重文章
30 | - 滚动时自动标记文章为已读
31 | - 高级功能:
32 | - 快捷键支持(可自定义)
33 | - 批量更新过滤后的订阅 URL 的 host(适用于替换 RSSHub 实例)
34 | - 批量刷新最近更新错误的订阅源
35 | - 保存文章到第三方服务
36 | - 多语言支持 (Deutsch / English / Español / Français / 简体中文)
37 | - 其他功能等您来发现...
38 |
39 | ## 在线演示和截图
40 |
41 | 试用 ReactFlux 的 [在线演示实例](https://reactflux.pages.dev)。
42 |
43 | 查看 ReactFlux 在不同主题下的外观:
44 |
45 | 
46 | 
47 |
48 | ## 快速开始
49 |
50 | 1. 确保您有一个正常运行的 Miniflux 实例
51 | 2. 直接使用我们的 [在线演示实例](https://reactflux.pages.dev) 或使用以下方法之一部署 ReactFlux
52 | 3. 使用您的 Miniflux 用户名和密码或 API 密钥(推荐)登录
53 |
54 | ## 部署
55 |
56 | ### Cloudflare Pages
57 |
58 | ReactFlux 使用 React 编写,构建完成后生成一组静态网页文件,可以直接部署在 Cloudflare Pages 上。
59 |
60 | 您可以通过选择 `Framework preset` 为 `Create React App` 来将其部署在 Cloudflare Pages 上。
61 |
62 | ### 使用预构建文件
63 |
64 | 您可以从 `gh-pages` 分支下载预构建文件,并将它们部署到任何支持单页应用程序 (SPA) 的静态托管服务。
65 |
66 | 确保配置 URL 重写,将所有请求重定向到 `index.html`。
67 |
68 | 如果您使用 Nginx 部署,可能需要添加以下配置:
69 |
70 | ```nginx
71 | location / {
72 | try_files $uri $uri/ /index.html;
73 | }
74 | ```
75 |
76 | 或者使用 Caddy,您可能需要添加以下配置:
77 |
78 | ```caddyfile
79 | try_files {path} {path}/ /index.html
80 | ```
81 |
82 | ### Vercel
83 |
84 | [](https://vercel.com/import/project?template=https://github.com/electh/ReactFlux)
85 |
86 | ### Docker
87 |
88 | [](https://hub.docker.com/r/electh/reactflux)
89 |
90 | ```bash
91 | docker run -p 2000:2000 electh/reactflux
92 | ```
93 |
94 | 或者使用 [Docker Compose](../docker-compose.yml):
95 |
96 | ```bash
97 | docker-compose up -d
98 | ```
99 |
100 |
103 |
104 | ## 翻译指南
105 |
106 | 为了帮助我们将 ReactFlux 翻译成您的语言,请贡献到 `locales` 文件夹并发送 pull request。
107 |
108 | 此外,您需要为相应的语言添加一个 README 文件,并在所有现有的 README 文件中引用它。
109 |
110 | 您还需要修改部分源代码,以包含 `Arco Design` 和 `Day.js` 的 i18n 语言包。
111 |
112 | 有关详细的更改,请参阅 [PR #145](https://github.com/electh/ReactFlux/pull/145) 中的修改。
113 |
114 | ### 当前翻译者
115 |
116 | | 语言 | 译者 |
117 | | -------- | ----------------------------------------------- |
118 | | Deutsch | [DonkeeeyKong](https://github.com/donkeeeykong) |
119 | | Español | [Victorhck](https://github.com/victorhck) |
120 | | Français | [MickGe](https://github.com/MickGe) |
121 | | 简体中文 | [Neko Aria](https://github.com/NekoAria) |
122 |
123 | ## 贡献者
124 |
125 | > 感谢所有让这个项目变得更加出色的贡献者!
126 |
127 |
128 |
129 |
130 |
131 | 使用 [contrib.rocks](https://contrib.rocks) 生成。
132 |
133 | ## 星标历史
134 |
135 | [](https://starchart.cc/electh/ReactFlux)
136 |
--------------------------------------------------------------------------------
/.github/workflows/update-fonts.yml:
--------------------------------------------------------------------------------
1 | name: Update Fonts
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * 6"
6 |
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: write
11 | pull-requests: write
12 |
13 | jobs:
14 | update-fonts:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: Checkout repository
19 | uses: actions/checkout@v6
20 |
21 | - name: Setup Node.js
22 | uses: actions/setup-node@v6
23 | with:
24 | node-version: "22"
25 |
26 | - name: Run font update script
27 | run: node scripts/update-fonts.js
28 |
29 | - name: Check for changes
30 | id: check-changes
31 | run: |
32 | if [[-n $(git status --porcelain) ]]; then
33 | echo "changes=true" >> $GITHUB_OUTPUT
34 | else
35 | echo "changes=false" >> $GITHUB_OUTPUT
36 | fi
37 |
38 | - name: Get update info
39 | if: steps.check-changes.outputs.changes == 'true'
40 | id: update-info
41 | run: |
42 | if [-f public/fonts/version.json]; then
43 | FONT_COUNT=$(jq -r '.fonts | length' public/fonts/version.json)
44 | FILE_COUNT=$(jq -r '[.fonts[].files] | add' public/fonts/version.json)
45 | echo "font_count=$FONT_COUNT" >> $GITHUB_OUTPUT
46 | echo "file_count=$FILE_COUNT" >> $GITHUB_OUTPUT
47 | fi
48 |
49 | - name: Commit changes
50 | if: steps.check-changes.outputs.changes == 'true'
51 | run: |
52 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
53 | git config --local user.name "github-actions[bot]"
54 | git add public/fonts public/styles/fonts.css
55 | git commit -m "chore(fonts): update fonts to latest versions"
56 |
57 | - name: Create Pull Request
58 | if: steps.check-changes.outputs.changes == 'true'
59 | uses: peter-evans/create-pull-request@v8
60 | with:
61 | title: "chore(fonts): update fonts to latest versions"
62 | body: |
63 | This PR was automatically created by GitHub Actions to update the locally hosted Google Fonts.
64 |
65 | ### Changes
66 | - **Font families updated**: ${{steps.update-info.outputs.font_count}}
67 | - **Total font files**: ${{steps.update-info.outputs.file_count}}
68 |
69 | ### Files Changed
70 | - `public/fonts/*/` - Updated font files (woff2)
71 | - `public/styles/fonts.css` - Updated CSS
72 | - `public/fonts/version.json` - Version tracking
73 |
74 | ---
75 | 🤖 Auto-generated by [Update Fonts workflow](${{ github.server_url}}/${{github.repository}}/actions/workflows/update-fonts.yml)
76 | branch: bot/update-fonts
77 | base: main
78 | delete-branch: true
79 | labels: |
80 | dependencies
81 | automated
82 |
83 | - name: Summary
84 | if: always()
85 | run: |
86 | echo "### Font Update Summary" >> $GITHUB_STEP_SUMMARY
87 | echo "" >> $GITHUB_STEP_SUMMARY
88 | if [ "${{ steps.check-changes.outputs.changes }}" == "true" ]; then
89 | echo "✅ Changes detected and PR created" >> $GITHUB_STEP_SUMMARY
90 | echo "" >> $GITHUB_STEP_SUMMARY
91 | echo "- Font families: ${{ steps.update-info.outputs.font_count }}" >> $GITHUB_STEP_SUMMARY
92 | echo "- Total files: ${{ steps.update-info.outputs.file_count }}" >> $GITHUB_STEP_SUMMARY
93 | else
94 | echo "ℹ️ No changes detected - fonts are up to date" >> $GITHUB_STEP_SUMMARY
95 | fi
96 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleCard.css:
--------------------------------------------------------------------------------
1 | .card-author {
2 | color: var(--color-text-3);
3 | margin-top: -3px;
4 | font-size: 0.7rem;
5 | font-weight: normal;
6 | overflow: hidden;
7 | text-overflow: ellipsis;
8 | white-space: nowrap;
9 | }
10 |
11 | .card-body {
12 | display: flex;
13 | gap: 8px;
14 | align-items: flex-start;
15 | }
16 |
17 | .card-content {
18 | display: flex;
19 | flex-direction: column;
20 | gap: 4px;
21 | will-change: opacity, background-color;
22 | transform: translateZ(0);
23 | }
24 |
25 | .card-image-mini {
26 | width: 80px;
27 | height: 80px;
28 | border-radius: var(--border-radius-medium);
29 | overflow: hidden;
30 | box-shadow: var(--shadow);
31 | }
32 |
33 | .card-image-wide {
34 | width: 100%;
35 | aspect-ratio: auto;
36 | margin: 4px 0;
37 | border-radius: var(--border-radius-medium);
38 | overflow: hidden;
39 | box-shadow: var(--shadow);
40 | }
41 |
42 | .card-image-wide .card-thumbnail img {
43 | position: unset;
44 | display: block;
45 | }
46 |
47 | .card-meta {
48 | display: flex;
49 | justify-content: space-between;
50 | align-items: flex-start;
51 | margin-bottom: 4px;
52 | gap: 4px;
53 | }
54 |
55 | .card-preview {
56 | color: var(--color-text-2);
57 | font-size: 0.9rem;
58 | margin: 0;
59 | display: -webkit-box;
60 | -webkit-box-orient: vertical;
61 | overflow: hidden;
62 | }
63 |
64 | .card-reading-time {
65 | display: flex;
66 | align-items: center;
67 | gap: 4px;
68 | color: var(--color-text-3);
69 | font-size: 0.75rem;
70 | margin-bottom: 2px;
71 | }
72 |
73 | .card-source {
74 | display: flex;
75 | align-items: center;
76 | gap: 4px;
77 | flex: 1;
78 | min-width: 0;
79 | }
80 |
81 | .card-source-content {
82 | display: flex;
83 | flex-direction: column;
84 | min-width: 0;
85 | }
86 |
87 | .card-source-title {
88 | color: var(--color-text-3);
89 | font-size: 0.75rem;
90 | font-weight: bold;
91 | }
92 |
93 | .card-star {
94 | margin-top: -2px;
95 | }
96 |
97 | .card-text {
98 | flex: 1;
99 | min-width: 0;
100 | }
101 |
102 | .card-thumbnail {
103 | width: 100%;
104 | height: 100%;
105 | position: relative;
106 | overflow: hidden;
107 | background-color: var(--color-neutral-3);
108 | }
109 |
110 | .card-thumbnail img {
111 | object-fit: cover;
112 | border-radius: 2px;
113 | position: absolute;
114 | left: 0;
115 | top: 0;
116 | }
117 |
118 | .card-time {
119 | white-space: nowrap;
120 | }
121 |
122 | .card-time-wrapper {
123 | color: var(--color-text-3);
124 | font-size: 0.75rem;
125 | display: flex;
126 | align-items: center;
127 | gap: 4px;
128 | }
129 |
130 | .card-title {
131 | color: var(--color-text-2);
132 | font-size: 1rem;
133 | font-weight: bold;
134 | margin: 0;
135 | display: -webkit-box;
136 | -webkit-line-clamp: 2;
137 | line-clamp: 2;
138 | -webkit-box-orient: vertical;
139 | overflow: hidden;
140 | }
141 |
142 | .card-wrapper {
143 | position: relative;
144 | border-radius: var(--border-radius-medium);
145 | background-color: transparent;
146 | cursor: pointer;
147 | transition: background-color 0.2s;
148 | padding: 12px;
149 | user-select: none;
150 | overflow: hidden;
151 | contain: content;
152 | will-change: background-color;
153 | transform: translateZ(0);
154 | }
155 |
156 | .card-wrapper:hover {
157 | background-color: var(--color-bg-2);
158 | }
159 |
160 | .card-wrapper.selected {
161 | background-color: var(--color-bg-2);
162 | box-shadow: var(--shadow);
163 | }
164 |
165 | .feed-icon-mini {
166 | margin-right: 4px;
167 | width: 18px;
168 | height: 18px;
169 | }
170 |
171 | .icon-starred {
172 | transition: opacity 0.2s;
173 | }
174 |
--------------------------------------------------------------------------------
/src/utils/highlighter.js:
--------------------------------------------------------------------------------
1 | import { Light as SyntaxHighlighter } from "react-syntax-highlighter"
2 | import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash"
3 | import c from "react-syntax-highlighter/dist/esm/languages/hljs/c"
4 | import cpp from "react-syntax-highlighter/dist/esm/languages/hljs/cpp"
5 | import csharp from "react-syntax-highlighter/dist/esm/languages/hljs/csharp"
6 | import css from "react-syntax-highlighter/dist/esm/languages/hljs/css"
7 | import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff"
8 | import dockerfile from "react-syntax-highlighter/dist/esm/languages/hljs/dockerfile"
9 | import go from "react-syntax-highlighter/dist/esm/languages/hljs/go"
10 | import ini from "react-syntax-highlighter/dist/esm/languages/hljs/ini"
11 | import java from "react-syntax-highlighter/dist/esm/languages/hljs/java"
12 | import javascript from "react-syntax-highlighter/dist/esm/languages/hljs/javascript"
13 | import json from "react-syntax-highlighter/dist/esm/languages/hljs/json"
14 | import kotlin from "react-syntax-highlighter/dist/esm/languages/hljs/kotlin"
15 | import lua from "react-syntax-highlighter/dist/esm/languages/hljs/lua"
16 | import markdown from "react-syntax-highlighter/dist/esm/languages/hljs/markdown"
17 | import php from "react-syntax-highlighter/dist/esm/languages/hljs/php"
18 | import phpTemplate from "react-syntax-highlighter/dist/esm/languages/hljs/php-template"
19 | import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext"
20 | import powershell from "react-syntax-highlighter/dist/esm/languages/hljs/powershell"
21 | import python from "react-syntax-highlighter/dist/esm/languages/hljs/python"
22 | import ruby from "react-syntax-highlighter/dist/esm/languages/hljs/ruby"
23 | import rust from "react-syntax-highlighter/dist/esm/languages/hljs/rust"
24 | import shell from "react-syntax-highlighter/dist/esm/languages/hljs/shell"
25 | import sql from "react-syntax-highlighter/dist/esm/languages/hljs/sql"
26 | import swift from "react-syntax-highlighter/dist/esm/languages/hljs/swift"
27 | import typescript from "react-syntax-highlighter/dist/esm/languages/hljs/typescript"
28 | import xml from "react-syntax-highlighter/dist/esm/languages/hljs/xml"
29 | import yaml from "react-syntax-highlighter/dist/esm/languages/hljs/yaml"
30 |
31 | const languages = {
32 | bash,
33 | c,
34 | cpp,
35 | csharp,
36 | css,
37 | diff,
38 | dockerfile,
39 | go,
40 | html: xml,
41 | ini,
42 | java,
43 | javascript,
44 | json,
45 | kotlin,
46 | lua,
47 | markdown,
48 | php,
49 | phpTemplate,
50 | plaintext,
51 | powershell,
52 | python,
53 | ruby,
54 | rust,
55 | shell,
56 | sql,
57 | swift,
58 | typescript,
59 | xml,
60 | yaml,
61 | }
62 |
63 | export const registerLanguages = () => {
64 | for (const [name, language] of Object.entries(languages)) {
65 | SyntaxHighlighter.registerLanguage(name, language)
66 | }
67 | }
68 |
69 | // https://highlightjs.org/download
70 | export const LANGUAGE_DISPLAY_NAMES = {
71 | bash: "Bash",
72 | c: "C",
73 | cpp: "C++",
74 | csharp: "C#",
75 | css: "CSS",
76 | diff: "Diff",
77 | dockerfile: "Dockerfile",
78 | go: "Go",
79 | html: "HTML",
80 | ini: "INI",
81 | java: "Java",
82 | javascript: "JavaScript",
83 | json: "JSON",
84 | kotlin: "Kotlin",
85 | lua: "Lua",
86 | markdown: "Markdown",
87 | php: "PHP",
88 | "php-template": "PHP Template",
89 | plaintext: "Plaintext",
90 | powershell: "PowerShell",
91 | python: "Python",
92 | ruby: "Ruby",
93 | rust: "Rust",
94 | shell: "Shell",
95 | sql: "SQL",
96 | swift: "Swift",
97 | typescript: "TypeScript",
98 | xml: "XML",
99 | yaml: "YAML",
100 | }
101 |
102 | export const SUPPORTED_LANGUAGES = Object.keys(LANGUAGE_DISPLAY_NAMES)
103 |
104 | export { Light as SyntaxHighlighter } from "react-syntax-highlighter"
105 |
--------------------------------------------------------------------------------
/src/components/Sidebar/Sidebar.css:
--------------------------------------------------------------------------------
1 | .arco-menu-icon-suffix {
2 | display: none;
3 | }
4 |
5 | .arco-menu-vertical .arco-menu-inline-header {
6 | padding-right: 12px;
7 | }
8 |
9 | .category-title,
10 | .custom-menu-item {
11 | display: flex;
12 | justify-content: space-between;
13 | align-items: center;
14 | }
15 |
16 | .unread-count {
17 | display: flex;
18 | justify-content: flex-end;
19 | color: var(--color-text-4);
20 | }
21 |
22 | .item-count {
23 | display: flex;
24 | justify-content: flex-end;
25 | width: 50%;
26 | color: var(--color-text-4);
27 | }
28 |
29 | .menu-header {
30 | display: flex;
31 | align-items: center;
32 | justify-content: space-between;
33 | margin-top: -4px;
34 | height: 48px;
35 | }
36 |
37 | .menu-header .avatar {
38 | margin-right: 10px;
39 | background-color: var(--color-text-1);
40 | }
41 |
42 | .section-title {
43 | color: var(--color-text-3);
44 | font-style: italic;
45 | }
46 |
47 | .custom-menu-item {
48 | display: flex;
49 | justify-content: space-between;
50 | align-items: center;
51 | }
52 |
53 | .custom-menu-item .item-count {
54 | display: flex;
55 | justify-content: flex-end;
56 | color: var(--color-text-4);
57 | }
58 |
59 | .category-title {
60 | display: flex;
61 | align-items: center;
62 | margin-right: -12px;
63 | margin-left: -32px;
64 | border-radius: var(--border-radius-medium);
65 | padding: 8px 12px 8px 42px;
66 | }
67 |
68 | .feed-icon-sidebar {
69 | vertical-align: -2px;
70 | margin-right: 8px;
71 | width: 16px;
72 | height: 16px;
73 | }
74 |
75 | .arco-collapse-item-header-title {
76 | width: 100%;
77 | }
78 |
79 | .arco-collapse {
80 | border-radius: 0 !important;
81 | }
82 |
83 | .arco-collapse-item-header {
84 | background-color: transparent;
85 | padding-top: 0;
86 | padding-bottom: 0;
87 | }
88 |
89 | .arco-collapse-item {
90 | border-color: transparent;
91 | }
92 |
93 | .arco-collapse-item-content-box {
94 | padding: 4px 0 0 0;
95 | }
96 |
97 | .arco-collapse-item-content {
98 | background-color: transparent;
99 | }
100 |
101 | .arco-collapse-item-active > .arco-collapse-item-header {
102 | border-color: transparent;
103 | background-color: transparent;
104 | }
105 |
106 | .category-title:hover {
107 | background-color: var(--color-neutral-3);
108 | }
109 |
110 | .submenu-active,
111 | .arco-menu-selected {
112 | background-color: var(--color-neutral-3) !important;
113 | color: rgb(var(--primary-6)) !important;
114 | }
115 |
116 | .submenu-inactive {
117 | color: var(--color-text-2);
118 | }
119 |
120 | .sidebar {
121 | position: fixed;
122 | top: 0;
123 | grid-area: sidebar;
124 | z-index: 999;
125 | height: 100%;
126 | background-color: var(--color-neutral-2);
127 | border-right: 1px solid var(--color-border-2);
128 | }
129 |
130 | @media screen and (max-width: 992px) {
131 | .sidebar {
132 | display: none;
133 | }
134 | }
135 |
136 | .arco-icon-hover.arco-collapse-item-icon-hover::before {
137 | width: 32px;
138 | height: 32px;
139 | }
140 |
141 | .arco-icon-hover.arco-collapse-item-icon-hover:hover::before {
142 | background-color: var(--color-neutral-3);
143 | }
144 |
145 | .sidebar-container {
146 | display: flex;
147 | flex-direction: column;
148 | height: 100%;
149 | }
150 |
151 | .sidebar-container .arco-menu-light,
152 | .sidebar-container .arco-menu-light .arco-menu-item,
153 | .sidebar-container .arco-menu-light .arco-menu-group-title,
154 | .sidebar-container .arco-menu-light .arco-menu-pop-header,
155 | .sidebar-container .arco-menu-light .arco-menu-inline-header {
156 | background-color: var(--color-neutral-2);
157 | }
158 |
159 | .arco-menu-light .arco-menu-item:hover,
160 | .arco-menu-light .arco-menu-group-title:hover,
161 | .arco-menu-light .arco-menu-pop-header:hover,
162 | .arco-menu-light .arco-menu-inline-header:hover {
163 | background-color: var(--color-neutral-3);
164 | }
165 |
--------------------------------------------------------------------------------
/src/components/Settings/EditableTagGroup.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Space, Tag } from "@arco-design/web-react"
2 | import { IconPlus, IconRefresh } from "@arco-design/web-react/icon"
3 | import { useStore } from "@nanostores/react"
4 | import { useEffect, useRef, useState } from "react"
5 |
6 | import EditableTag from "./EditableTag"
7 |
8 | import { duplicateHotkeysState, resetHotkey, updateHotkey } from "@/store/hotkeysState"
9 |
10 | const capitalizeFirstLetter = (word) => word.charAt(0).toUpperCase() + word.slice(1)
11 |
12 | const processKeyName = (keys) =>
13 | keys
14 | .map((key) => {
15 | const modifiedKey = key
16 | .replace("left", "←")
17 | .replace("right", "→")
18 | .replace("up", "↑")
19 | .replace("down", "↓")
20 | return modifiedKey.includes("+")
21 | ? modifiedKey
22 | .split("+")
23 | .map((k) => capitalizeFirstLetter(k))
24 | .join(" + ")
25 | : capitalizeFirstLetter(modifiedKey)
26 | })
27 | .join(" / ")
28 |
29 | const EditableTagGroup = ({ keys, record }) => {
30 | const duplicateHotkeys = useStore(duplicateHotkeysState)
31 |
32 | const [isEditing, setIsEditing] = useState(false)
33 |
34 | const groupRef = useRef(null)
35 |
36 | useEffect(() => {
37 | const handleClickOutside = (event) => {
38 | if (groupRef.current && !groupRef.current.contains(event.target)) {
39 | setIsEditing(false)
40 | }
41 | }
42 |
43 | document.addEventListener("mousedown", handleClickOutside)
44 | return () => {
45 | document.removeEventListener("mousedown", handleClickOutside)
46 | }
47 | }, [])
48 |
49 | useEffect(() => {
50 | if (!isEditing) {
51 | const newKeys = keys.filter((key) => key !== "")
52 | if (newKeys.length !== keys.length) {
53 | updateHotkey(record.action, newKeys)
54 | }
55 | }
56 | }, [isEditing])
57 |
58 | return (
59 |
60 | {isEditing ? (
61 |
62 | {keys.map((key, keyIndex) => (
63 | {
68 | const newKeys = [...keys]
69 | newKeys[keyIndex] = newKey
70 | updateHotkey(record.action, newKeys)
71 | }}
72 | onRemove={() => {
73 | const newKeys = keys.filter((_, filterIndex) => filterIndex !== keyIndex)
74 | updateHotkey(record.action, newKeys)
75 | }}
76 | />
77 | ))}
78 | }
80 | style={{
81 | backgroundColor: "var(--color-bg-1)",
82 | border: "1px dashed var(--color-border-2)",
83 | cursor: "pointer",
84 | width: "32px",
85 | }}
86 | onClick={() => {
87 | const newKeys = [...keys, ""]
88 | updateHotkey(record.action, newKeys)
89 | }}
90 | />
91 | }
93 | shape="circle"
94 | size="mini"
95 | onClick={() => resetHotkey(record.action)}
96 | />
97 |
98 | ) : (
99 | duplicateHotkeys.includes(key))
103 | ? "var(--color-danger-light-4)"
104 | : "var(--color-fill-2)",
105 | color: keys.some((key) => duplicateHotkeys.includes(key))
106 | ? "white"
107 | : "var(--color-text-1)",
108 | }}
109 | onClick={() => setIsEditing(true)}
110 | >
111 | {processKeyName(keys)}
112 |
113 | )}
114 |
115 | )
116 | }
117 |
118 | export default EditableTagGroup
119 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleList.jsx:
--------------------------------------------------------------------------------
1 | import { Divider, Spin } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 | import { throttle } from "lodash-es"
4 | import { forwardRef, useCallback, useEffect, useMemo } from "react"
5 | import { useInView } from "react-intersection-observer"
6 | import SimpleBar from "simplebar-react"
7 | import { Virtualizer } from "virtua"
8 |
9 | import ArticleCard from "./ArticleCard"
10 | import LoadingCards from "./LoadingCards"
11 |
12 | import FadeTransition from "@/components/ui/FadeTransition"
13 | import Ripple from "@/components/ui/Ripple"
14 | import useLoadMore from "@/hooks/useLoadMore"
15 | import { contentState, filteredEntriesState } from "@/store/contentState"
16 |
17 | import "./ArticleList.css"
18 |
19 | const LoadMoreComponent = ({ getEntries }) => {
20 | const { isArticleListReady, loadMoreVisible } = useStore(contentState)
21 |
22 | const { loadingMore, handleLoadMore } = useLoadMore()
23 |
24 | const { ref: loadMoreRef, inView } = useInView()
25 |
26 | const loadMoreEntries = useCallback(async () => {
27 | if (loadMoreVisible && inView && isArticleListReady && !loadingMore) {
28 | await handleLoadMore(getEntries)
29 | }
30 | }, [loadMoreVisible, inView, isArticleListReady, loadingMore, handleLoadMore, getEntries])
31 |
32 | useEffect(() => {
33 | const intervalId = setInterval(loadMoreEntries, 500)
34 |
35 | return () => clearInterval(intervalId)
36 | }, [loadMoreEntries])
37 |
38 | return (
39 | isArticleListReady &&
40 | loadMoreVisible && (
41 |
42 |
43 | Loading more ...
44 |
45 | )
46 | )
47 | }
48 |
49 | const ArticleList = forwardRef(({ getEntries, handleEntryClick, cardsRef }, ref) => {
50 | const { isArticleListReady, loadMoreVisible } = useStore(contentState)
51 | const filteredEntries = useStore(filteredEntriesState)
52 |
53 | const { loadingMore, handleLoadMore } = useLoadMore()
54 | const canLoadMore = loadMoreVisible && isArticleListReady && !loadingMore
55 |
56 | const checkAndLoadMore = useMemo(
57 | () =>
58 | throttle((element) => {
59 | if (!canLoadMore) {
60 | return
61 | }
62 |
63 | const threshold = element.scrollHeight * 0.8
64 | const scrolledDistance = element.scrollTop + element.clientHeight
65 | if (scrolledDistance >= threshold) {
66 | handleLoadMore(getEntries)
67 | }
68 | }, 200),
69 | [canLoadMore, handleLoadMore, getEntries],
70 | )
71 |
72 | return (
73 |
74 |
75 | {isArticleListReady && (
76 |
77 | {
81 | const element = cardsRef.current
82 | if (element) {
83 | checkAndLoadMore(element)
84 | }
85 | }}
86 | >
87 | {filteredEntries.map((entry, index) => (
88 |
89 |
90 |
91 |
92 | {index < filteredEntries.length - 1 && (
93 |
99 | )}
100 |
101 | ))}
102 |
103 |
104 | )}
105 |
106 |
107 | )
108 | })
109 | ArticleList.displayName = "ArticleList"
110 |
111 | export default ArticleList
112 |
--------------------------------------------------------------------------------
/src/components/Article/ImageOverlayButton.jsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from "@arco-design/web-react"
2 | import { useStore } from "@nanostores/react"
3 | import { useEffect, useState } from "react"
4 |
5 | import ImageLinkTag from "./ImageLinkTag"
6 |
7 | import { settingsState } from "@/store/settingsState"
8 | import { MIN_THUMBNAIL_SIZE } from "@/utils/constants"
9 |
10 | import "./ImageOverlayButton.css"
11 |
12 | const ImageComponent = ({ imgNode, isIcon, isBigImage, index, togglePhotoSlider }) => {
13 | const { fontSize } = useStore(settingsState)
14 | const altText = imgNode.attribs.alt
15 |
16 | return isIcon ? (
17 |
18 |
26 |
27 | ) : (
28 |
29 |
![{altText}]()
30 |
31 |
40 |
41 | )
42 | }
43 |
44 | const findImageNode = (node, isLinkWrapper) =>
45 | isLinkWrapper ? node.children.find((child) => child.type === "tag" && child.name === "img") : node
46 |
47 | const ImageOverlayButton = ({ node, index, togglePhotoSlider, isLinkWrapper = false }) => {
48 | const [isIcon, setIsIcon] = useState(false)
49 | const [isBigImage, setIsBigImage] = useState(false)
50 |
51 | const imgNode = findImageNode(node, isLinkWrapper)
52 |
53 | useEffect(() => {
54 | let isSubscribed = true
55 |
56 | const imgNode = findImageNode(node, isLinkWrapper)
57 | const imgSrc = imgNode.attribs.src
58 | const img = new Image()
59 | img.src = imgSrc
60 |
61 | const handleLoad = () => {
62 | if (isSubscribed) {
63 | const isSmall = Math.max(img.width, img.height) <= MIN_THUMBNAIL_SIZE
64 | const isLarge = img.width > 768
65 |
66 | setIsIcon(isSmall)
67 | setIsBigImage(isLarge && !isSmall)
68 | }
69 | }
70 |
71 | img.addEventListener("load", handleLoad)
72 |
73 | return () => {
74 | isSubscribed = false
75 | img.src = ""
76 | img.removeEventListener("load", handleLoad)
77 | }
78 | }, [node, isLinkWrapper])
79 |
80 | if (isIcon) {
81 | return isLinkWrapper ? (
82 |
83 |
90 | {node.children[1]?.data}
91 |
92 | ) : (
93 |
100 | )
101 | }
102 |
103 | return (
104 |
105 |
106 | {isLinkWrapper ? (
107 |
108 |
115 |
116 |
117 | ) : (
118 |
125 | )}
126 |
127 |
128 | )
129 | }
130 |
131 | export default ImageOverlayButton
132 |
--------------------------------------------------------------------------------
/src/hooks/useCategoryOperations.js:
--------------------------------------------------------------------------------
1 | import { Message, Modal, Notification } from "@arco-design/web-react"
2 |
3 | import { addCategory, deleteCategory, updateCategory } from "@/apis/categories"
4 | import { polyglotState } from "@/hooks/useLanguage"
5 | import { setCategoriesData, setFeedsData } from "@/store/dataState"
6 |
7 | const useCategoryOperations = (useNotification = false) => {
8 | const { polyglot } = polyglotState.get()
9 |
10 | const showMessage = (message, type = "success") => {
11 | if (useNotification) {
12 | Notification[type]({ title: message })
13 | } else {
14 | Message[type](message)
15 | }
16 | }
17 |
18 | const addNewCategory = async (title) => {
19 | if (!title?.trim()) {
20 | return false
21 | }
22 |
23 | try {
24 | const data = await addCategory(title.trim())
25 | setCategoriesData((prevCategories) => [...prevCategories, { ...data }])
26 |
27 | const successMessage = polyglot.t("category_list.add_category_success")
28 | showMessage(successMessage)
29 | return true
30 | } catch (error) {
31 | console.error(`${polyglot.t("category_list.add_category_error")}:`, error)
32 |
33 | const errorMessage = polyglot.t("category_list.add_category_error")
34 | showMessage(errorMessage, "error")
35 | return false
36 | }
37 | }
38 |
39 | const editCategory = async (categoryId, newTitle, hidden) => {
40 | try {
41 | const data = await updateCategory(categoryId, newTitle, hidden)
42 |
43 | // Update feeds that belong to this category
44 | setFeedsData((prevFeeds) =>
45 | prevFeeds.map((feed) =>
46 | feed.category.id === categoryId
47 | ? {
48 | ...feed,
49 | category: {
50 | ...feed.category,
51 | title: newTitle,
52 | hide_globally: hidden,
53 | },
54 | }
55 | : feed,
56 | ),
57 | )
58 |
59 | // Update categories list
60 | setCategoriesData((prevCategories) =>
61 | prevCategories.map((category) =>
62 | category.id === categoryId ? { ...category, ...data } : category,
63 | ),
64 | )
65 |
66 | const successMessage = polyglot.t("category_list.update_category_success")
67 | showMessage(successMessage)
68 | return true
69 | } catch (error) {
70 | console.error("Failed to update category:", error)
71 | const errorMessage = polyglot.t("category_list.update_category_error")
72 | showMessage(errorMessage, "error")
73 | return false
74 | }
75 | }
76 |
77 | const deleteCategoryDirectly = async (category) => {
78 | try {
79 | const response = await deleteCategory(category.id)
80 | if (response.status === 204) {
81 | setCategoriesData((prevCategories) => prevCategories.filter((c) => c.id !== category.id))
82 |
83 | const successMessage = polyglot.t("category_list.remove_category_success", {
84 | title: category.title,
85 | })
86 | showMessage(successMessage)
87 | return true
88 | } else {
89 | throw new Error(`Unexpected status: ${response.status}`)
90 | }
91 | } catch (error) {
92 | console.error(`Failed to delete category: ${category.title}`, error)
93 |
94 | const errorMessage = polyglot.t("category_list.remove_category_error", {
95 | title: category.title,
96 | })
97 | showMessage(errorMessage, "error")
98 | return false
99 | }
100 | }
101 |
102 | const handleDeleteCategory = async (category, requireConfirmation = true) => {
103 | if (requireConfirmation) {
104 | Modal.confirm({
105 | title: polyglot.t("sidebar.delete_category_confirm_title"),
106 | content: polyglot.t("sidebar.delete_category_confirm_content", {
107 | title: category.title,
108 | }),
109 | onOk: () => deleteCategoryDirectly(category),
110 | })
111 | } else {
112 | return deleteCategoryDirectly(category)
113 | }
114 | }
115 |
116 | return {
117 | addNewCategory,
118 | editCategory,
119 | deleteCategoryDirectly,
120 | handleDeleteCategory,
121 | }
122 | }
123 |
124 | export default useCategoryOperations
125 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import globals from "globals"
2 | import importPlugin from "eslint-plugin-import-x"
3 | import js from "@eslint/js"
4 | import prettier from "eslint-config-prettier"
5 | import promise from "eslint-plugin-promise"
6 | import react from "eslint-plugin-react"
7 | import reactCompiler from "eslint-plugin-react-compiler"
8 | import reactHooks from "eslint-plugin-react-hooks"
9 | import reactRefresh from "eslint-plugin-react-refresh"
10 | import unicorn from "eslint-plugin-unicorn"
11 |
12 | export default [
13 | {
14 | ignores: ["build", "dev-dist"],
15 | },
16 | js.configs.recommended,
17 | unicorn.configs.recommended,
18 | prettier,
19 | {
20 | files: ["**/*.{js,jsx}"],
21 | languageOptions: {
22 | globals: globals.browser,
23 | parserOptions: {
24 | ecmaFeatures: { jsx: true },
25 | projectService: true,
26 | },
27 | },
28 | plugins: {
29 | import: importPlugin,
30 | promise,
31 | react,
32 | "react-compiler": reactCompiler,
33 | "react-hooks": reactHooks,
34 | "react-refresh": reactRefresh,
35 | },
36 | rules: {
37 | curly: ["error", "all"],
38 | "no-unused-vars": "off",
39 | "operator-assignment": "error",
40 | "prefer-destructuring": [
41 | "error",
42 | {
43 | VariableDeclarator: {
44 | array: false,
45 | object: true,
46 | },
47 | },
48 | ],
49 | "prefer-template": "error",
50 |
51 | // Import rules
52 | "import/extensions": [
53 | "error",
54 | "never",
55 | {
56 | css: "always",
57 | json: "always",
58 | },
59 | ],
60 | "import/no-anonymous-default-export": "error",
61 | "import/no-cycle": "error",
62 | "import/no-duplicates": "error",
63 | "import/no-relative-parent-imports": "error",
64 | "import/no-self-import": "error",
65 | "import/no-useless-path-segments": ["error", { noUselessIndex: true }],
66 | "import/order": [
67 | "error",
68 | {
69 | alphabetize: {
70 | caseInsensitive: true,
71 | order: "asc",
72 | },
73 | groups: [
74 | "builtin",
75 | "external",
76 | "internal",
77 | "parent",
78 | "sibling",
79 | "index",
80 | "object",
81 | "type",
82 | ],
83 | named: true,
84 | "newlines-between": "always",
85 | },
86 | ],
87 | "import/prefer-default-export": "warn",
88 |
89 | // Promise rules
90 | "promise/always-return": "error",
91 | "promise/catch-or-return": "error",
92 | "promise/no-nesting": "error",
93 | "promise/no-return-wrap": "error",
94 | "promise/param-names": "error",
95 |
96 | // React rules
97 | ...react.configs.recommended.rules,
98 | ...react.configs["jsx-runtime"].rules,
99 | ...reactHooks.configs.recommended.rules,
100 | "react-compiler/react-compiler": "error",
101 | "react/jsx-no-target-blank": "off",
102 | "react/jsx-sort-props": [
103 | "error",
104 | {
105 | callbacksLast: true,
106 | reservedFirst: true,
107 | shorthandFirst: true,
108 | multiline: "last",
109 | },
110 | ],
111 | "react/no-unused-state": "warn",
112 | "react/prop-types": "off",
113 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
114 |
115 | // unicorn rules
116 | "unicorn/no-null": "off",
117 | "unicorn/prevent-abbreviations": "off",
118 | },
119 | settings: {
120 | react: { version: "detect" },
121 | },
122 | },
123 | {
124 | files: ["src/**/*.jsx"],
125 | rules: {
126 | "unicorn/filename-case": ["error", { case: "pascalCase" }],
127 | },
128 | },
129 | {
130 | files: ["src/components/Article/ArticleTOC.jsx", "src/main.jsx", "src/routes.jsx"],
131 | rules: {
132 | "unicorn/filename-case": "off",
133 | },
134 | },
135 | {
136 | files: ["src/hooks/**/*.js", "src/store/**/*.js"],
137 | rules: {
138 | "unicorn/filename-case": ["error", { case: "camelCase" }],
139 | },
140 | },
141 | {
142 | files: ["scripts/update-fonts.js"],
143 | languageOptions: { globals: globals.node },
144 | rules: {
145 | "unicorn/no-process-exit": "off",
146 | },
147 | },
148 | ]
149 |
--------------------------------------------------------------------------------
/src/store/dataState.js:
--------------------------------------------------------------------------------
1 | import { computed, map } from "nanostores"
2 |
3 | import { settingsState } from "./settingsState"
4 |
5 | import { sortMixedLanguageArray } from "@/utils/locales"
6 | import createSetter from "@/utils/nanostores"
7 |
8 | const defaultValue = {
9 | isAppDataReady: false,
10 | isCoreDataReady: false,
11 | unreadInfo: {},
12 | unreadStarredCount: 0,
13 | unreadTodayCount: 0,
14 | starredCount: 0,
15 | historyCount: 0,
16 | feedsData: [],
17 | categoriesData: [],
18 | version: "",
19 | hasIntegrations: false,
20 | }
21 |
22 | export const dataState = map(defaultValue)
23 |
24 | export const feedsState = computed([dataState, settingsState], (data, settings) => {
25 | const { unreadInfo, feedsData } = data
26 | const { language } = settings
27 |
28 | const feedsWithUnread = feedsData.map((feed) => ({
29 | ...feed,
30 | unreadCount: unreadInfo[feed.id] ?? 0,
31 | }))
32 |
33 | return sortMixedLanguageArray(feedsWithUnread, "title", language)
34 | })
35 |
36 | export const categoriesState = computed(
37 | [dataState, feedsState, settingsState],
38 | (data, feeds, settings) => {
39 | const { categoriesData } = data
40 | const { language } = settings
41 |
42 | const categoriesWithUnread = categoriesData.map((category) => {
43 | const feedsInCategory = feeds.filter((feed) => feed.category.id === category.id)
44 | return {
45 | ...category,
46 | unreadCount: feedsInCategory.reduce((acc, feed) => acc + (feed.unreadCount ?? 0), 0),
47 | feedCount: feedsInCategory.length,
48 | }
49 | })
50 |
51 | return sortMixedLanguageArray(categoriesWithUnread, "title", language)
52 | },
53 | )
54 |
55 | export const hiddenCategoryIdsState = computed(categoriesState, (categories) => {
56 | return categories.filter((category) => category.hide_globally).map((category) => category.id)
57 | })
58 |
59 | export const hiddenFeedIdsState = computed(
60 | [feedsState, hiddenCategoryIdsState],
61 | (feeds, hiddenCategoryIds) => {
62 | return feeds
63 | .filter((feed) => feed.hide_globally || hiddenCategoryIds.includes(feed.category.id))
64 | .map((feed) => feed.id)
65 | },
66 | )
67 |
68 | export const filteredFeedsState = computed(
69 | [feedsState, hiddenFeedIdsState, settingsState],
70 | (feeds, hiddenFeedIds, settings) => {
71 | const { showHiddenFeeds } = settings
72 | return feeds.filter((feed) => showHiddenFeeds || !hiddenFeedIds.includes(feed.id))
73 | },
74 | )
75 |
76 | export const filteredCategoriesState = computed(
77 | [categoriesState, hiddenCategoryIdsState, settingsState],
78 | (categories, hiddenCategoryIds, settings) => {
79 | const { showHiddenFeeds } = settings
80 | return categories.filter(
81 | (category) => showHiddenFeeds || !hiddenCategoryIds.includes(category.id),
82 | )
83 | },
84 | )
85 |
86 | export const feedsGroupedByIdState = computed(filteredFeedsState, (filteredFeeds) => {
87 | const groupedFeeds = {}
88 |
89 | for (const feed of filteredFeeds) {
90 | const { id } = feed.category
91 |
92 | if (!groupedFeeds[id]) {
93 | groupedFeeds[id] = []
94 | }
95 |
96 | groupedFeeds[id].push(feed)
97 | }
98 |
99 | return groupedFeeds
100 | })
101 |
102 | export const unreadTotalState = computed([dataState, filteredFeedsState], (data, filteredFeeds) => {
103 | const { unreadInfo } = data
104 | let total = 0
105 |
106 | for (const [id, count] of Object.entries(unreadInfo)) {
107 | if (filteredFeeds.some((feed) => feed.id === Number(id))) {
108 | total += count
109 | }
110 | }
111 |
112 | return total
113 | })
114 |
115 | export const setCategoriesData = createSetter(dataState, "categoriesData")
116 | export const setFeedsData = createSetter(dataState, "feedsData")
117 | export const setHasIntegrations = createSetter(dataState, "hasIntegrations")
118 | export const setHistoryCount = createSetter(dataState, "historyCount")
119 | export const setIsAppDataReady = createSetter(dataState, "isAppDataReady")
120 | export const setIsCoreDataReady = (isCoreDataReady) => {
121 | dataState.setKey("isCoreDataReady", isCoreDataReady)
122 | }
123 | export const setStarredCount = createSetter(dataState, "starredCount")
124 | export const setUnreadInfo = createSetter(dataState, "unreadInfo")
125 | export const setUnreadStarredCount = createSetter(dataState, "unreadStarredCount")
126 | export const setUnreadTodayCount = createSetter(dataState, "unreadTodayCount")
127 | export const setVersion = createSetter(dataState, "version")
128 | export const resetData = () => dataState.set(defaultValue)
129 |
--------------------------------------------------------------------------------
/src/components/Sidebar/Profile.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Divider, Dropdown, Menu, Modal, Notification, Radio } from "@arco-design/web-react"
2 | import {
3 | IconDesktop,
4 | IconExclamationCircle,
5 | IconInfoCircleFill,
6 | IconLink,
7 | IconMoonFill,
8 | IconPoweroff,
9 | IconRefresh,
10 | IconSettings,
11 | IconSunFill,
12 | IconUser,
13 | } from "@arco-design/web-react/icon"
14 | import { useStore } from "@nanostores/react"
15 | import { useNavigate } from "react-router"
16 |
17 | import { polyglotState } from "@/hooks/useLanguage"
18 | import useModalToggle from "@/hooks/useModalToggle"
19 | import { authState, resetAuth } from "@/store/authState"
20 | import { resetContent } from "@/store/contentState"
21 | import { resetData } from "@/store/dataState"
22 | import { resetFeedIcons } from "@/store/feedIconsState"
23 | import { resetSettings, settingsState, updateSettings } from "@/store/settingsState"
24 | import { GITHUB_REPO_PATH } from "@/utils/constants"
25 | import "./Profile.css"
26 |
27 | export default function Profile() {
28 | const navigate = useNavigate()
29 | const { server } = useStore(authState)
30 | const { polyglot } = useStore(polyglotState)
31 |
32 | const { themeMode } = useStore(settingsState)
33 |
34 | const { setSettingsModalVisible } = useModalToggle()
35 |
36 | const handleResetSettings = () => {
37 | Modal.confirm({
38 | title: polyglot.t("sidebar.settings_reset_confirm"),
39 | content: {polyglot.t("sidebar.settings_reset_description")}
,
40 | icon: ,
41 | okButtonProps: { status: "danger" },
42 | onOk: () => resetSettings(),
43 | })
44 | }
45 |
46 | const handleLogout = () => {
47 | Modal.confirm({
48 | title: polyglot.t("sidebar.logout_confirm"),
49 | content: {polyglot.t("sidebar.logout_description")}
,
50 | icon: ,
51 | okButtonProps: { status: "danger" },
52 | onOk: () => {
53 | resetAuth()
54 | resetContent()
55 | resetData()
56 | resetFeedIcons()
57 | navigate("/login")
58 | Notification.success({
59 | title: polyglot.t("sidebar.logout_success"),
60 | })
61 | },
62 | })
63 | }
64 |
65 | return (
66 |
67 |
68 |
73 | updateSettings({ themeMode: value })}
84 | >
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | setSettingsModalVisible(true)}>
97 |
98 | {polyglot.t("sidebar.settings")}
99 |
100 | window.open(`${server}/settings`, "_blank")}>
101 |
102 | {polyglot.t("sidebar.miniflux_settings")}
103 |
104 |
107 | window.open(`https://github.com/${GITHUB_REPO_PATH}/issues/new/choose`, "_blank")
108 | }
109 | >
110 |
111 | {polyglot.t("sidebar.report_issue")}
112 |
113 |
114 |
115 |
116 | {polyglot.t("sidebar.reset_settings")}
117 |
118 |
119 |
120 | {polyglot.t("sidebar.logout")}
121 |
122 |
123 | }
124 | >
125 | } shape="circle" size="small" style={{ marginRight: 8 }} />
126 |
127 |
128 |
129 | )
130 | }
131 |
--------------------------------------------------------------------------------
/src/hooks/useAppData.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from "react"
2 |
3 | import {
4 | getCategories,
5 | getCounters,
6 | getFeeds,
7 | getIntegrationsStatus,
8 | getTodayEntries,
9 | getVersion,
10 | } from "@/apis"
11 | import {
12 | setCategoriesData,
13 | setFeedsData,
14 | setHasIntegrations,
15 | setHistoryCount,
16 | setIsAppDataReady,
17 | setIsCoreDataReady,
18 | setUnreadInfo,
19 | setUnreadTodayCount,
20 | setVersion,
21 | } from "@/store/dataState"
22 | import compareVersions from "@/utils/version"
23 |
24 | const useAppData = () => {
25 | const isLoading = useRef(false)
26 |
27 | const fetchCounters = useCallback(async () => {
28 | try {
29 | const countersData = await getCounters()
30 | const historyCount = Object.values(countersData.reads).reduce((acc, count) => acc + count, 0)
31 | setHistoryCount(historyCount)
32 | return countersData
33 | } catch (error) {
34 | console.error("Error fetching counters:", error)
35 | return { reads: {}, unreads: {} }
36 | }
37 | }, [])
38 |
39 | const fetchUnreadToday = useCallback(async () => {
40 | try {
41 | const unreadTodayData = await getTodayEntries("unread")
42 | setUnreadTodayCount(unreadTodayData.total ?? 0)
43 | return unreadTodayData
44 | } catch (error) {
45 | console.error("Error fetching unread today entries:", error)
46 | setUnreadTodayCount(0)
47 | return { total: 0 }
48 | }
49 | }, [])
50 |
51 | const fetchFeeds = useCallback(async () => {
52 | try {
53 | const feedsData = await getFeeds()
54 | setFeedsData(feedsData)
55 | return feedsData
56 | } catch (error) {
57 | console.error("Error fetching feeds:", error)
58 | return []
59 | }
60 | }, [])
61 |
62 | const fetchCategories = useCallback(async () => {
63 | try {
64 | const categoriesData = await getCategories()
65 | setCategoriesData(categoriesData)
66 | return categoriesData
67 | } catch (error) {
68 | console.error("Error fetching categories:", error)
69 | return []
70 | }
71 | }, [])
72 |
73 | const updateUnreadInfo = useCallback((feeds, counters) => {
74 | if (!feeds || !counters) {
75 | return
76 | }
77 |
78 | const unreadInfo = {}
79 | for (const feed of feeds) {
80 | unreadInfo[feed.id] = counters.unreads[feed.id] ?? 0
81 | }
82 | setUnreadInfo(unreadInfo)
83 | }, [])
84 |
85 | const fetchIntegrationStatus = useCallback(async (version) => {
86 | if (compareVersions(version, "2.2.2") < 0) {
87 | return false
88 | }
89 |
90 | try {
91 | const integrationsStatus = await getIntegrationsStatus()
92 | const hasIntegrations = !!integrationsStatus.has_integrations
93 | setHasIntegrations(hasIntegrations)
94 | return hasIntegrations
95 | } catch (error) {
96 | console.error("Error fetching integration status:", error)
97 | return false
98 | }
99 | }, [])
100 |
101 | const fetchFeedRelatedData = useCallback(async () => {
102 | if (isLoading.current) {
103 | return
104 | }
105 |
106 | isLoading.current = true
107 |
108 | try {
109 | const [counters, feeds] = await Promise.all([fetchCounters(), fetchFeeds()])
110 |
111 | updateUnreadInfo(feeds, counters)
112 | await fetchUnreadToday()
113 |
114 | return { counters, feeds }
115 | } catch (error) {
116 | console.error("Error fetching feed related data:", error)
117 | } finally {
118 | isLoading.current = false
119 | }
120 | }, [fetchCounters, fetchFeeds, fetchUnreadToday, updateUnreadInfo])
121 |
122 | const fetchAppData = useCallback(async () => {
123 | if (isLoading.current) {
124 | return
125 | }
126 |
127 | isLoading.current = true
128 | setIsAppDataReady(false)
129 | setIsCoreDataReady(false)
130 |
131 | try {
132 | const [feeds, categories] = await Promise.all([fetchFeeds(), fetchCategories()])
133 |
134 | setIsCoreDataReady(true)
135 |
136 | const [counters, versionData, todayData] = await Promise.all([
137 | fetchCounters(),
138 | getVersion(),
139 | fetchUnreadToday(),
140 | ])
141 |
142 | const { version } = versionData
143 | setVersion(version)
144 | await fetchIntegrationStatus(version)
145 |
146 | updateUnreadInfo(feeds, counters)
147 |
148 | setIsAppDataReady(true)
149 | return { counters, feeds, categories, version, todayData }
150 | } catch (error) {
151 | console.error("Error fetching app data:", error)
152 | } finally {
153 | isLoading.current = false
154 | }
155 | }, [
156 | fetchCategories,
157 | fetchCounters,
158 | fetchFeeds,
159 | fetchIntegrationStatus,
160 | fetchUnreadToday,
161 | updateUnreadInfo,
162 | ])
163 |
164 | return { fetchAppData, fetchCounters, fetchFeedRelatedData }
165 | }
166 |
167 | export default useAppData
168 |
--------------------------------------------------------------------------------