replace complex eslint config with biome

This commit is contained in:
Athou
2024-06-13 21:54:14 +02:00
parent 9115797dee
commit 3810dedf47
102 changed files with 7186 additions and 9488 deletions

View File

@@ -1,8 +0,0 @@
dist
node_modules
vite.config.ts
# compiled linguijs locales
# they no longer exist but we keep this to avoid issues with people still having those files on disk
src/locales/**/*.ts

View File

@@ -1,52 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"standard",
"love",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended",
],
settings: {
react: {
version: "detect",
},
},
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parserOptions: {
project: true,
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react"],
rules: {
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/prefer-nullish-coalescing": ["error", { ignoreConditionalTests: true }],
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
"@typescript-eslint/strict-boolean-expressions": "off",
"react/jsx-curly-brace-presence": ["error", "never"],
"react/no-unescaped-entities": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "error",
},
}

View File

@@ -1,8 +0,0 @@
{
"printWidth": 140,
"semi": false,
"tabWidth": 4,
"arrowParens": "avoid",
"endOfLine": "auto",
"trailingComma": "es5"
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 140
},
"javascript": {
"formatter": {
"trailingCommas": "es5",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded"
}
},
"files": {
"ignore": ["dist", "node_modules", "target", "target-ide"]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +1,79 @@
{ {
"name": "commafeed-client", "name": "commafeed-client",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"dev:typescript": "tsc --watch", "dev:typescript": "tsc --watch",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:ci": "vitest run", "test:ci": "vitest run",
"eslint": "eslint --ext=.js,.jsx,.ts,.tsx src", "lint": "biome check ./src",
"i18n:extract": "lingui extract --clean" "lint:fix": "biome check --write ./src",
}, "i18n:extract": "lingui extract --clean"
"dependencies": { },
"@emotion/react": "^11.11.4", "dependencies": {
"@fontsource/open-sans": "^5.0.28", "@emotion/react": "^11.11.4",
"@lingui/core": "^4.11.1", "@fontsource/open-sans": "^5.0.28",
"@lingui/macro": "^4.11.1", "@lingui/core": "^4.11.1",
"@lingui/react": "^4.11.1", "@lingui/macro": "^4.11.1",
"@mantine/core": "^7.10.1", "@lingui/react": "^4.11.1",
"@mantine/form": "^7.10.1", "@mantine/core": "^7.10.1",
"@mantine/hooks": "^7.10.1", "@mantine/form": "^7.10.1",
"@mantine/modals": "^7.10.1", "@mantine/hooks": "^7.10.1",
"@mantine/notifications": "^7.10.1", "@mantine/modals": "^7.10.1",
"@mantine/spotlight": "^7.10.1", "@mantine/notifications": "^7.10.1",
"@monaco-editor/react": "^4.6.0", "@mantine/spotlight": "^7.10.1",
"@reduxjs/toolkit": "^2.2.5", "@monaco-editor/react": "^4.6.0",
"axios": "^1.7.2", "@reduxjs/toolkit": "^2.2.5",
"dayjs": "^1.11.11", "axios": "^1.7.2",
"escape-string-regexp": "^5.0.0", "dayjs": "^1.11.11",
"interweave": "^13.1.0", "escape-string-regexp": "^5.0.0",
"monaco-editor": "^0.49.0", "interweave": "^13.1.0",
"mousetrap": "^1.6.5", "monaco-editor": "^0.49.0",
"react": "^18.3.1", "mousetrap": "^1.6.5",
"react-async-hook": "^4.0.0", "react": "^18.3.1",
"react-contexify": "^6.0.0", "react-async-hook": "^4.0.0",
"react-device-detect": "^2.2.3", "react-contexify": "^6.0.0",
"react-dom": "^18.3.1", "react-device-detect": "^2.2.3",
"react-draggable": "^4.4.6", "react-dom": "^18.3.1",
"react-ga4": "^2.1.0", "react-draggable": "^4.4.6",
"react-helmet": "^6.1.0", "react-ga4": "^2.1.0",
"react-icons": "^5.2.1", "react-helmet": "^6.1.0",
"react-infinite-scroller": "^1.2.6", "react-icons": "^5.2.1",
"react-redux": "^9.1.2", "react-infinite-scroller": "^1.2.6",
"react-router-dom": "^6.23.1", "react-redux": "^9.1.2",
"react-swipeable": "^7.0.1", "react-router-dom": "^6.23.1",
"redoc": "^2.1.5", "react-swipeable": "^7.0.1",
"throttle-debounce": "^5.0.0", "redoc": "^2.1.5",
"tinycon": "^0.6.8", "throttle-debounce": "^5.0.0",
"tss-react": "^4.9.10", "tinycon": "^0.6.8",
"use-local-storage": "^3.0.0", "tss-react": "^4.9.10",
"websocket-heartbeat-js": "^1.1.3" "use-local-storage": "^3.0.0",
}, "vite-plugin-biome": "^1.0.10",
"devDependencies": { "websocket-heartbeat-js": "^1.1.3"
"@lingui/cli": "^4.11.1", },
"@lingui/vite-plugin": "^4.11.1", "devDependencies": {
"@types/mousetrap": "^1.6.15", "@biomejs/biome": "^1.8.1",
"@types/react": "^18.3.3", "@lingui/cli": "^4.11.1",
"@types/react-dom": "^18.3.0", "@lingui/vite-plugin": "^4.11.1",
"@types/react-helmet": "^6.1.11", "@types/mousetrap": "^1.6.15",
"@types/react-infinite-scroller": "^1.2.5", "@types/react": "^18.3.3",
"@types/swagger-ui-react": "^4.18.3", "@types/react-dom": "^18.3.0",
"@types/throttle-debounce": "^5.0.2", "@types/react-helmet": "^6.1.11",
"@types/tinycon": "^0.6.5", "@types/react-infinite-scroller": "^1.2.5",
"@typescript-eslint/eslint-plugin": "^7.13.0", "@types/swagger-ui-react": "^4.18.3",
"@vitejs/plugin-react": "^4.3.1", "@types/throttle-debounce": "^5.0.2",
"babel-plugin-macros": "^3.1.0", "@types/tinycon": "^0.6.5",
"eslint": "^8.57.0", "@vitejs/plugin-react": "^4.3.1",
"eslint-config-love": "^47.0.0", "babel-plugin-macros": "^3.1.0",
"eslint-config-prettier": "^9.1.0", "rollup-plugin-visualizer": "^5.12.0",
"eslint-config-standard": "^17.1.0", "typescript": "^5.4.5",
"eslint-plugin-prettier": "^5.1.3", "vite": "^5.2.13",
"eslint-plugin-react": "^7.34.2", "vite-tsconfig-paths": "^4.3.2",
"eslint-plugin-react-hooks": "^4.6.2", "vitest": "^1.6.0",
"prettier": "^3.3.2", "vitest-mock-extended": "^1.3.1"
"rollup-plugin-visualizer": "^5.12.0", }
"typescript": "^5.4.5",
"vite": "^5.2.13",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.0",
"vitest-mock-extended": "^1.3.1"
}
} }

View File

@@ -14,6 +14,7 @@ import { Header } from "components/header/Header"
import { Tree } from "components/sidebar/Tree" import { Tree } from "components/sidebar/Tree"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useI18n } from "i18n" import { useI18n } from "i18n"
import { WelcomePage } from "pages/WelcomePage"
import { AdminUsersPage } from "pages/admin/AdminUsersPage" import { AdminUsersPage } from "pages/admin/AdminUsersPage"
import { MetricsPage } from "pages/admin/MetricsPage" import { MetricsPage } from "pages/admin/MetricsPage"
import { AboutPage } from "pages/app/AboutPage" import { AboutPage } from "pages/app/AboutPage"
@@ -28,7 +29,6 @@ import { TagDetailsPage } from "pages/app/TagDetailsPage"
import { LoginPage } from "pages/auth/LoginPage" import { LoginPage } from "pages/auth/LoginPage"
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage" import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
import { RegistrationPage } from "pages/auth/RegistrationPage" import { RegistrationPage } from "pages/auth/RegistrationPage"
import { WelcomePage } from "pages/WelcomePage"
import React, { useEffect } from "react" import React, { useEffect } from "react"
import { isSafari } from "react-device-detect" import { isSafari } from "react-device-detect"
import ReactGA from "react-ga4" import ReactGA from "react-ga4"

View File

@@ -1,7 +1,7 @@
import { createAsyncThunk } from "@reduxjs/toolkit" import { createAsyncThunk } from "@reduxjs/toolkit"
import { type AppDispatch, type RootState } from "app/store" import type { AppDispatch, RootState } from "app/store"
export const createAppAsyncThunk = createAsyncThunk.withTypes<{ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState state: RootState
dispatch: AppDispatch dispatch: AppDispatch
}>() }>()

View File

@@ -1,127 +1,127 @@
import axios, { type AxiosError } from "axios" import axios, { type AxiosError } from "axios"
import { import type {
type AddCategoryRequest, AddCategoryRequest,
type AdminSaveUserRequest, AdminSaveUserRequest,
type AuthenticationError, AuthenticationError,
type Category, Category,
type CategoryModificationRequest, CategoryModificationRequest,
type CollapseRequest, CollapseRequest,
type Entries, Entries,
type FeedInfo, FeedInfo,
type FeedInfoRequest, FeedInfoRequest,
type FeedModificationRequest, FeedModificationRequest,
type GetEntriesPaginatedRequest, GetEntriesPaginatedRequest,
type IDRequest, IDRequest,
type LoginRequest, LoginRequest,
type MarkRequest, MarkRequest,
type Metrics, Metrics,
type MultipleMarkRequest, MultipleMarkRequest,
type PasswordResetRequest, PasswordResetRequest,
type ProfileModificationRequest, ProfileModificationRequest,
type RegistrationRequest, RegistrationRequest,
type ServerInfo, ServerInfo,
type Settings, Settings,
type StarRequest, StarRequest,
type SubscribeRequest, SubscribeRequest,
type Subscription, Subscription,
type TagRequest, TagRequest,
type UserModel, UserModel,
} from "./types" } from "./types"
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true }) const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
response => response, response => response,
error => { error => {
if (isAuthenticationError(error)) { if (isAuthenticationError(error)) {
const data = error.response?.data const data = error.response?.data
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login" window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
} }
throw error throw error
} }
) )
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> { function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status) return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status)
} }
export const client = { export const client = {
category: { category: {
getRoot: async () => await axiosInstance.get<Category>("category/get"), getRoot: async () => await axiosInstance.get<Category>("category/get"),
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req), modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req), collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }), getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req), markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req), add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req), delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
}, },
entry: { entry: {
mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req), mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req),
markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req), markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req),
star: async (req: StarRequest) => await axiosInstance.post("entry/star", req), star: async (req: StarRequest) => await axiosInstance.post("entry/star", req),
getTags: async () => await axiosInstance.get<string[]>("entry/tags"), getTags: async () => await axiosInstance.get<string[]>("entry/tags"),
tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req), tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req),
}, },
feed: { feed: {
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`), get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req), modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }), getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req), markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req), fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
refreshAll: async () => await axiosInstance.get("feed/refreshAll"), refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req), subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req),
unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req), unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req),
importOpml: async (req: File) => { importOpml: async (req: File) => {
const formData = new FormData() const formData = new FormData()
formData.append("file", req) formData.append("file", req)
return await axiosInstance.post("feed/import", formData, { return await axiosInstance.post("feed/import", formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
}) })
}, },
}, },
user: { user: {
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req), login: async (req: LoginRequest) => await axiosInstance.post("user/login", req),
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req), register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req), passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
getSettings: async () => await axiosInstance.get<Settings>("user/settings"), getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings), saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"), getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req), saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"), deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
}, },
server: { server: {
getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"), getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"),
}, },
admin: { admin: {
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"), getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req), saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req), deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"), getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
}, },
} }
/** /**
* transform an error object to an array of strings that can be displayed to the user * transform an error object to an array of strings that can be displayed to the user
* @param err an error object (e.g. from axios) * @param err an error object (e.g. from axios)
* @returns an array of messages to show the user * @returns an array of messages to show the user
*/ */
export const errorToStrings = (err: unknown) => { export const errorToStrings = (err: unknown) => {
let strings: string[] = [] let strings: string[] = []
if (axios.isAxiosError(err) && err.response) { if (axios.isAxiosError(err) && err.response) {
if (typeof err.response.data === "string") strings.push(err.response.data) if (typeof err.response.data === "string") strings.push(err.response.data)
if (isMessageError(err)) strings.push(err.response.data.message) if (isMessageError(err)) strings.push(err.response.data.message)
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors] if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
} }
return strings return strings
} }
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> { function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
} }
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> { function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
} }

View File

@@ -1,112 +1,112 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { type IconType } from "react-icons" import type { IconType } from "react-icons"
import { FaAt } from "react-icons/fa" import { FaAt } from "react-icons/fa"
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si" import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si"
import { type Category, type Entry, type SharingSettings } from "./types" import type { Category, Entry, SharingSettings } from "./types"
const categories: Record<string, Category> = { const categories: Record<string, Category> = {
all: { all: {
id: "all", id: "all",
name: t`All`, name: t`All`,
expanded: false, expanded: false,
children: [], children: [],
feeds: [], feeds: [],
position: 0, position: 0,
}, },
starred: { starred: {
id: "starred", id: "starred",
name: t`Starred`, name: t`Starred`,
expanded: false, expanded: false,
children: [], children: [],
feeds: [], feeds: [],
position: 1, position: 1,
}, },
} }
const sharing: { const sharing: {
[key in keyof SharingSettings]: { [key in keyof SharingSettings]: {
label: string label: string
icon: IconType icon: IconType
color: `#${string}` color: `#${string}`
url: (url: string, description: string) => string url: (url: string, description: string) => string
} }
} = { } = {
email: { email: {
label: "Email", label: "Email",
icon: FaAt, icon: FaAt,
color: "#000000", color: "#000000",
url: (url, desc) => `mailto:?subject=${desc}&body=${url}`, url: (url, desc) => `mailto:?subject=${desc}&body=${url}`,
}, },
gmail: { gmail: {
label: "Gmail", label: "Gmail",
icon: SiGmail, icon: SiGmail,
color: "#EA4335", color: "#EA4335",
url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`, url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`,
}, },
facebook: { facebook: {
label: "Facebook", label: "Facebook",
icon: SiFacebook, icon: SiFacebook,
color: "#1B74E4", color: "#1B74E4",
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`, url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
}, },
twitter: { twitter: {
label: "Twitter", label: "Twitter",
icon: SiTwitter, icon: SiTwitter,
color: "#1D9BF0", color: "#1D9BF0",
url: (url, desc) => `https://twitter.com/share?text=${desc}&url=${url}`, url: (url, desc) => `https://twitter.com/share?text=${desc}&url=${url}`,
}, },
tumblr: { tumblr: {
label: "Tumblr", label: "Tumblr",
icon: SiTumblr, icon: SiTumblr,
color: "#375672", color: "#375672",
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`, url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
}, },
pocket: { pocket: {
label: "Pocket", label: "Pocket",
icon: SiPocket, icon: SiPocket,
color: "#EF4154", color: "#EF4154",
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`, url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
}, },
instapaper: { instapaper: {
label: "Instapaper", label: "Instapaper",
icon: SiInstapaper, icon: SiInstapaper,
color: "#010101", color: "#010101",
url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`, url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`,
}, },
buffer: { buffer: {
label: "Buffer", label: "Buffer",
icon: SiBuffer, icon: SiBuffer,
color: "#000000", color: "#000000",
url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`, url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`,
}, },
} }
export const Constants = { export const Constants = {
categories, categories,
sharing, sharing,
layout: { layout: {
mobileBreakpoint: 992, mobileBreakpoint: 992,
mobileBreakpointName: "md", mobileBreakpointName: "md",
headerHeight: 60, headerHeight: 60,
entryMaxWidth: 650, entryMaxWidth: 650,
isTopVisible: (div: HTMLElement) => { isTopVisible: (div: HTMLElement) => {
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect() const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
return div.getBoundingClientRect().top >= (header?.bottom ?? 0) return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
}, },
isBottomVisible: (div: HTMLElement) => { isBottomVisible: (div: HTMLElement) => {
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect() const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight) return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
}, },
}, },
dom: { dom: {
headerId: "header", headerId: "header",
footerId: "footer", footerId: "footer",
entryId: (entry: Entry) => `entry-id-${entry.id}`, entryId: (entry: Entry) => `entry-id-${entry.id}`,
entryContextMenuId: (entry: Entry) => entry.id, entryContextMenuId: (entry: Entry) => entry.id,
}, },
tooltip: { tooltip: {
delay: 500, delay: 500,
}, },
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension", browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e", bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
} }

View File

@@ -1,145 +1,145 @@
import { configureStore } from "@reduxjs/toolkit" import { configureStore } from "@reduxjs/toolkit"
import { type client } from "app/client" import type { client } from "app/client"
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks" import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
import { reducers, type RootState } from "app/store" import { type RootState, reducers } from "app/store"
import { type Entries, type Entry } from "app/types" import type { Entries, Entry } from "app/types"
import { type AxiosResponse } from "axios" import type { AxiosResponse } from "axios"
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
import { mockReset } from "vitest-mock-extended" import { mockReset } from "vitest-mock-extended"
const mockClient = await vi.hoisted(async () => { const mockClient = await vi.hoisted(async () => {
const mockModule = await import("vitest-mock-extended") const mockModule = await import("vitest-mock-extended")
return mockModule.mockDeep<typeof client>() return mockModule.mockDeep<typeof client>()
}) })
vi.mock("app/client", () => ({ client: mockClient })) vi.mock("app/client", () => ({ client: mockClient }))
describe("entries", () => { describe("entries", () => {
beforeEach(() => { beforeEach(() => {
mockReset(mockClient) mockReset(mockClient)
}) })
it("loads entries", async () => { it("loads entries", async () => {
mockClient.feed.getEntries.mockResolvedValue({ mockClient.feed.getEntries.mockResolvedValue({
data: { data: {
entries: [{ id: "3" } as Entry], entries: [{ id: "3" } as Entry],
hasMore: false, hasMore: false,
name: "my-feed", name: "my-feed",
errorCount: 3, errorCount: 3,
feedLink: "https://mysite.com/feed", feedLink: "https://mysite.com/feed",
timestamp: 123, timestamp: 123,
ignoredReadStatus: false, ignoredReadStatus: false,
}, },
} as AxiosResponse<Entries>) } as AxiosResponse<Entries>)
const store = configureStore({ reducer: reducers }) const store = configureStore({ reducer: reducers })
const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true })) const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
expect(store.getState().entries.source.type).toBe("feed") expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id") expect(store.getState().entries.source.id).toBe("feed-id")
expect(store.getState().entries.entries).toStrictEqual([]) expect(store.getState().entries.entries).toStrictEqual([])
expect(store.getState().entries.hasMore).toBe(true) expect(store.getState().entries.hasMore).toBe(true)
expect(store.getState().entries.sourceLabel).toBe("") expect(store.getState().entries.sourceLabel).toBe("")
expect(store.getState().entries.sourceWebsiteUrl).toBe("") expect(store.getState().entries.sourceWebsiteUrl).toBe("")
expect(store.getState().entries.timestamp).toBeUndefined() expect(store.getState().entries.timestamp).toBeUndefined()
await promise await promise
expect(store.getState().entries.source.type).toBe("feed") expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id") expect(store.getState().entries.source.id).toBe("feed-id")
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }]) expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
expect(store.getState().entries.hasMore).toBe(false) expect(store.getState().entries.hasMore).toBe(false)
expect(store.getState().entries.sourceLabel).toBe("my-feed") expect(store.getState().entries.sourceLabel).toBe("my-feed")
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed") expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
expect(store.getState().entries.timestamp).toBe(123) expect(store.getState().entries.timestamp).toBe(123)
}) })
it("loads more entries", async () => { it("loads more entries", async () => {
mockClient.category.getEntries.mockResolvedValue({ mockClient.category.getEntries.mockResolvedValue({
data: { data: {
entries: [{ id: "4" } as Entry], entries: [{ id: "4" } as Entry],
hasMore: false, hasMore: false,
name: "my-feed", name: "my-feed",
errorCount: 3, errorCount: 3,
feedLink: "https://mysite.com/feed", feedLink: "https://mysite.com/feed",
timestamp: 123, timestamp: 123,
ignoredReadStatus: false, ignoredReadStatus: false,
}, },
} as AxiosResponse<Entries>) } as AxiosResponse<Entries>)
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,
preloadedState: { preloadedState: {
entries: { entries: {
source: { source: {
type: "category", type: "category",
id: "category-id", id: "category-id",
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3" } as Entry], entries: [{ id: "3" } as Entry],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
}, },
} as RootState, } as RootState,
}) })
const promise = store.dispatch(loadMoreEntries()) const promise = store.dispatch(loadMoreEntries())
await promise await promise
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }]) expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
expect(store.getState().entries.hasMore).toBe(false) expect(store.getState().entries.hasMore).toBe(false)
}) })
it("marks an entry as read", () => { it("marks an entry as read", () => {
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,
preloadedState: { preloadedState: {
entries: { entries: {
source: { source: {
type: "category", type: "category",
id: "category-id", id: "category-id",
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
}, },
} as RootState, } as RootState,
}) })
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true })) store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
expect(store.getState().entries.entries).toStrictEqual([ expect(store.getState().entries.entries).toStrictEqual([
{ id: "3", read: true }, { id: "3", read: true },
{ id: "4", read: false }, { id: "4", read: false },
]) ])
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true }) expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
}) })
it("marks all entries as read", () => { it("marks all entries as read", () => {
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,
preloadedState: { preloadedState: {
entries: { entries: {
source: { source: {
type: "category", type: "category",
id: "category-id", id: "category-id",
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
}, },
} as RootState, } as RootState,
}) })
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } })) store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
expect(store.getState().entries.entries).toStrictEqual([ expect(store.getState().entries.entries).toStrictEqual([
{ id: "3", read: true }, { id: "3", read: true },
{ id: "4", read: true }, { id: "4", read: true },
]) ])
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true }) expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
}) })
}) })

View File

@@ -1,134 +1,122 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "app/entries/thunks" import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "app/entries/thunks"
import { type Entry } from "app/types" import type { Entry } from "app/types"
export type EntrySourceType = "category" | "feed" | "tag" export type EntrySourceType = "category" | "feed" | "tag"
export interface EntrySource { export interface EntrySource {
type: EntrySourceType type: EntrySourceType
id: string id: string
} }
export type ExpendableEntry = Entry & { expanded?: boolean } export type ExpendableEntry = Entry & { expanded?: boolean }
interface EntriesState { interface EntriesState {
/** selected source */ /** selected source */
source: EntrySource source: EntrySource
sourceLabel: string sourceLabel: string
sourceWebsiteUrl: string sourceWebsiteUrl: string
entries: ExpendableEntry[] entries: ExpendableEntry[]
/** stores when the first batch of entries were retrieved /** stores when the first batch of entries were retrieved
* *
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown * this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
*/ */
timestamp?: number timestamp?: number
selectedEntryId?: string selectedEntryId?: string
hasMore: boolean hasMore: boolean
loading: boolean loading: boolean
search?: string search?: string
scrollingToEntry: boolean scrollingToEntry: boolean
} }
const initialState: EntriesState = { const initialState: EntriesState = {
source: { source: {
type: "category", type: "category",
id: Constants.categories.all.id, id: Constants.categories.all.id,
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [], entries: [],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
} }
export const entriesSlice = createSlice({ export const entriesSlice = createSlice({
name: "entries", name: "entries",
initialState, initialState,
reducers: { reducers: {
setSelectedEntry: (state, action: PayloadAction<Entry>) => { setSelectedEntry: (state, action: PayloadAction<Entry>) => {
state.selectedEntryId = action.payload.id state.selectedEntryId = action.payload.id
}, },
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => { setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
state.entries for (const e of state.entries.filter(e => e.id === action.payload.entry.id)) {
.filter(e => e.id === action.payload.entry.id) e.expanded = action.payload.expanded
.forEach(e => { }
e.expanded = action.payload.expanded },
}) setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
}, state.scrollingToEntry = action.payload
setScrollingToEntry: (state, action: PayloadAction<boolean>) => { },
state.scrollingToEntry = action.payload setSearch: (state, action: PayloadAction<string>) => {
}, state.search = action.payload
setSearch: (state, action: PayloadAction<string>) => { },
state.search = action.payload },
}, extraReducers: builder => {
}, builder.addCase(markEntry.pending, (state, action) => {
extraReducers: builder => { for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
builder.addCase(markEntry.pending, (state, action) => { e.read = action.meta.arg.read
state.entries }
.filter(e => e.id === action.meta.arg.entry.id) })
.forEach(e => { builder.addCase(markMultipleEntries.pending, (state, action) => {
e.read = action.meta.arg.read for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
}) e.read = action.meta.arg.read
}) }
builder.addCase(markMultipleEntries.pending, (state, action) => { })
state.entries builder.addCase(markAllEntries.pending, (state, action) => {
.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id)) for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
.forEach(e => { e.read = true
e.read = action.meta.arg.read }
}) })
}) builder.addCase(starEntry.pending, (state, action) => {
builder.addCase(markAllEntries.pending, (state, action) => { for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
state.entries e.starred = action.meta.arg.starred
.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true)) }
.forEach(e => { })
e.read = true builder.addCase(loadEntries.pending, (state, action) => {
}) state.source = action.meta.arg.source
}) state.entries = []
builder.addCase(starEntry.pending, (state, action) => { state.timestamp = undefined
state.entries state.sourceLabel = ""
.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId) state.sourceWebsiteUrl = ""
.forEach(e => { state.hasMore = true
e.starred = action.meta.arg.starred state.selectedEntryId = undefined
}) state.loading = true
}) })
builder.addCase(loadEntries.pending, (state, action) => { builder.addCase(loadMoreEntries.pending, state => {
state.source = action.meta.arg.source state.loading = true
state.entries = [] })
state.timestamp = undefined builder.addCase(loadEntries.fulfilled, (state, action) => {
state.sourceLabel = "" state.entries = action.payload.entries
state.sourceWebsiteUrl = "" state.timestamp = action.payload.timestamp
state.hasMore = true state.sourceLabel = action.payload.name
state.selectedEntryId = undefined state.sourceWebsiteUrl = action.payload.feedLink
state.loading = true state.hasMore = action.payload.hasMore
}) state.loading = false
builder.addCase(loadMoreEntries.pending, state => { })
state.loading = true builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
}) // remove already existing entries
builder.addCase(loadEntries.fulfilled, (state, action) => { const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
state.entries = action.payload.entries state.entries = [...state.entries, ...entriesToAdd]
state.timestamp = action.payload.timestamp state.hasMore = action.payload.hasMore
state.sourceLabel = action.payload.name state.loading = false
state.sourceWebsiteUrl = action.payload.feedLink })
state.hasMore = action.payload.hasMore builder.addCase(tagEntry.pending, (state, action) => {
state.loading = false for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
}) e.tags = action.meta.arg.tags
builder.addCase(loadMoreEntries.fulfilled, (state, action) => { }
// remove already existing entries })
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id)) },
state.entries = [...state.entries, ...entriesToAdd] })
state.hasMore = action.payload.hasMore
state.loading = false export const { setSearch } = entriesSlice.actions
})
builder.addCase(tagEntry.pending, (state, action) => {
state.entries
.filter(e => +e.id === action.meta.arg.entryId)
.forEach(e => {
e.tags = action.meta.arg.tags
})
})
},
})
export const { setSearch } = entriesSlice.actions

View File

@@ -1,247 +1,247 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { entriesSlice, type EntrySource, type EntrySourceType, setSearch } from "app/entries/slice" import { type EntrySource, type EntrySourceType, entriesSlice, setSearch } from "app/entries/slice"
import type { RootState } from "app/store" import type { RootState } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import type { Entry, MarkRequest, TagRequest } from "app/types" import type { Entry, MarkRequest, TagRequest } from "app/types"
import { reloadTags } from "app/user/thunks" import { reloadTags } from "app/user/thunks"
import { scrollToWithCallback } from "app/utils" import { scrollToWithCallback } from "app/utils"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
const getEndpoint = (sourceType: EntrySourceType) => const getEndpoint = (sourceType: EntrySourceType) =>
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
export const loadEntries = createAppAsyncThunk( export const loadEntries = createAppAsyncThunk(
"entries/load", "entries/load",
async ( async (
arg: { arg: {
source: EntrySource source: EntrySource
clearSearch: boolean clearSearch: boolean
}, },
thunkApi thunkApi
) => { ) => {
if (arg.clearSearch) thunkApi.dispatch(setSearch("")) if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
const state = thunkApi.getState() const state = thunkApi.getState()
const endpoint = getEndpoint(arg.source.type) const endpoint = getEndpoint(arg.source.type)
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0)) const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
return result.data return result.data
} }
) )
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => { export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { source } = state.entries const { source } = state.entries
const offset = const offset =
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type) const endpoint = getEndpoint(state.entries.source.type)
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset)) const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
return result.data return result.data
}) })
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
id: source.type === "tag" ? Constants.categories.all.id : source.id, id: source.type === "tag" ? Constants.categories.all.id : source.id,
order: state.user.settings?.readingOrder, order: state.user.settings?.readingOrder,
readType: state.user.settings?.readingMode, readType: state.user.settings?.readingMode,
offset, offset,
limit: 50, limit: 50,
tag: source.type === "tag" ? source.id : undefined, tag: source.type === "tag" ? source.id : undefined,
keywords: state.entries.search, keywords: state.entries.search,
}) })
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => { export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false })) thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
}) })
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => { export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
thunkApi.dispatch(setSearch(arg)) thunkApi.dispatch(setSearch(arg))
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false })) thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
}) })
export const markEntry = createAppAsyncThunk( export const markEntry = createAppAsyncThunk(
"entries/entry/mark", "entries/entry/mark",
(arg: { entry: Entry; read: boolean }) => { (arg: { entry: Entry; read: boolean }) => {
client.entry.mark({ client.entry.mark({
id: arg.entry.id, id: arg.entry.id,
read: arg.read, read: arg.read,
}) })
}, },
{ {
condition: arg => arg.entry.markable && arg.entry.read !== arg.read, condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
} }
) )
export const markMultipleEntries = createAppAsyncThunk( export const markMultipleEntries = createAppAsyncThunk(
"entries/entry/markMultiple", "entries/entry/markMultiple",
async ( async (
arg: { arg: {
entries: Entry[] entries: Entry[]
read: boolean read: boolean
}, },
thunkApi thunkApi
) => { ) => {
const requests: MarkRequest[] = arg.entries.map(e => ({ const requests: MarkRequest[] = arg.entries.map(e => ({
id: e.id, id: e.id,
read: arg.read, read: arg.read,
})) }))
await client.entry.markMultiple({ requests }) await client.entry.markMultiple({ requests })
thunkApi.dispatch(reloadTree()) thunkApi.dispatch(reloadTree())
} }
) )
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => { export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { entries } = state.entries const { entries } = state.entries
const index = entries.findIndex(e => e.id === arg.id) const index = entries.findIndex(e => e.id === arg.id)
if (index === -1) return if (index === -1) return
thunkApi.dispatch( thunkApi.dispatch(
markMultipleEntries({ markMultipleEntries({
entries: entries.slice(0, index + 1), entries: entries.slice(0, index + 1),
read: true, read: true,
}) })
) )
}) })
export const markAllEntries = createAppAsyncThunk( export const markAllEntries = createAppAsyncThunk(
"entries/entry/markAll", "entries/entry/markAll",
async ( async (
arg: { arg: {
sourceType: EntrySourceType sourceType: EntrySourceType
req: MarkRequest req: MarkRequest
}, },
thunkApi thunkApi
) => { ) => {
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
await endpoint(arg.req) await endpoint(arg.req)
thunkApi.dispatch(reloadEntries()) thunkApi.dispatch(reloadEntries())
thunkApi.dispatch(reloadTree()) thunkApi.dispatch(reloadTree())
} }
) )
export const starEntry = createAppAsyncThunk( export const starEntry = createAppAsyncThunk(
"entries/entry/star", "entries/entry/star",
(arg: { entry: Entry; starred: boolean }) => { (arg: { entry: Entry; starred: boolean }) => {
client.entry.star({ client.entry.star({
id: arg.entry.id, id: arg.entry.id,
feedId: +arg.entry.feedId, feedId: +arg.entry.feedId,
starred: arg.starred, starred: arg.starred,
}) })
}, },
{ {
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred, condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
} }
) )
export const selectEntry = createAppAsyncThunk( export const selectEntry = createAppAsyncThunk(
"entries/entry/select", "entries/entry/select",
( (
arg: { arg: {
entry: Entry entry: Entry
expand: boolean expand: boolean
markAsRead: boolean markAsRead: boolean
scrollToEntry: boolean scrollToEntry: boolean
}, },
thunkApi thunkApi
) => { ) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const entry = state.entries.entries.find(e => e.id === arg.entry.id) const entry = state.entries.entries.find(e => e.id === arg.entry.id)
if (!entry) return if (!entry) return
// flushSync is required because we need the newly selected entry to be expanded // flushSync is required because we need the newly selected entry to be expanded
// and the previously selected entry to be collapsed to be able to scroll to the right position // and the previously selected entry to be collapsed to be able to scroll to the right position
flushSync(() => { flushSync(() => {
// mark as read if requested // mark as read if requested
if (arg.markAsRead) { if (arg.markAsRead) {
thunkApi.dispatch(markEntry({ entry, read: true })) thunkApi.dispatch(markEntry({ entry, read: true }))
} }
// set entry as selected // set entry as selected
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry)) thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
// expand if requested // expand if requested
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId) const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
if (previouslySelectedEntry) { if (previouslySelectedEntry) {
thunkApi.dispatch( thunkApi.dispatch(
entriesSlice.actions.setEntryExpanded({ entriesSlice.actions.setEntryExpanded({
entry: previouslySelectedEntry, entry: previouslySelectedEntry,
expanded: false, expanded: false,
}) })
) )
} }
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand })) thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
}) })
if (arg.scrollToEntry) { if (arg.scrollToEntry) {
const entryElement = document.getElementById(Constants.dom.entryId(entry)) const entryElement = document.getElementById(Constants.dom.entryId(entry))
if (entryElement) { if (entryElement) {
const scrollMode = state.user.settings?.scrollMode const scrollMode = state.user.settings?.scrollMode
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement) const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) { if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
const scrollSpeed = state.user.settings?.scrollSpeed const scrollSpeed = state.user.settings?.scrollSpeed
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true)) thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))) scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
} }
} }
} }
} }
) )
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => { const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect() const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
const offset = (header?.bottom ?? 0) + 3 const offset = (header?.bottom ?? 0) + 3
scrollToWithCallback({ scrollToWithCallback({
options: { options: {
top: entryElement.offsetTop - offset, top: entryElement.offsetTop - offset,
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto", behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
}, },
onScrollEnded, onScrollEnded,
}) })
} }
export const selectPreviousEntry = createAppAsyncThunk( export const selectPreviousEntry = createAppAsyncThunk(
"entries/entry/selectPrevious", "entries/entry/selectPrevious",
( (
arg: { arg: {
expand: boolean expand: boolean
markAsRead: boolean markAsRead: boolean
scrollToEntry: boolean scrollToEntry: boolean
}, },
thunkApi thunkApi
) => { ) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { entries } = state.entries const { entries } = state.entries
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1 const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
if (previousIndex >= 0) { if (previousIndex >= 0) {
thunkApi.dispatch( thunkApi.dispatch(
selectEntry({ selectEntry({
entry: entries[previousIndex], entry: entries[previousIndex],
expand: arg.expand, expand: arg.expand,
markAsRead: arg.markAsRead, markAsRead: arg.markAsRead,
scrollToEntry: arg.scrollToEntry, scrollToEntry: arg.scrollToEntry,
}) })
) )
} }
} }
) )
export const selectNextEntry = createAppAsyncThunk( export const selectNextEntry = createAppAsyncThunk(
"entries/entry/selectNext", "entries/entry/selectNext",
( (
arg: { arg: {
expand: boolean expand: boolean
markAsRead: boolean markAsRead: boolean
scrollToEntry: boolean scrollToEntry: boolean
}, },
thunkApi thunkApi
) => { ) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { entries } = state.entries const { entries } = state.entries
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1 const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
if (nextIndex < entries.length) { if (nextIndex < entries.length) {
thunkApi.dispatch( thunkApi.dispatch(
selectEntry({ selectEntry({
entry: entries[nextIndex], entry: entries[nextIndex],
expand: arg.expand, expand: arg.expand,
markAsRead: arg.markAsRead, markAsRead: arg.markAsRead,
scrollToEntry: arg.scrollToEntry, scrollToEntry: arg.scrollToEntry,
}) })
) )
} }
} }
) )
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => { export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
await client.entry.tag(arg) await client.entry.tag(arg)
thunkApi.dispatch(reloadTags()) thunkApi.dispatch(reloadTags())
}) })

View File

@@ -1,10 +1,10 @@
import { redirectToCategory } from "app/redirect/thunks" import { redirectToCategory } from "app/redirect/thunks"
import { store } from "app/store" import { store } from "app/store"
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
describe("redirects", () => { describe("redirects", () => {
it("redirects to category", async () => { it("redirects to category", async () => {
await store.dispatch(redirectToCategory("1")) await store.dispatch(redirectToCategory("1"))
expect(store.getState().redirect.to).toBe("/app/category/1") expect(store.getState().redirect.to).toBe("/app/category/1")
}) })
}) })

View File

@@ -1,19 +1,19 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
interface RedirectState { interface RedirectState {
to?: string to?: string
} }
const initialState: RedirectState = {} const initialState: RedirectState = {}
export const redirectSlice = createSlice({ export const redirectSlice = createSlice({
name: "redirect", name: "redirect",
initialState, initialState,
reducers: { reducers: {
redirectTo: (state, action: PayloadAction<string | undefined>) => { redirectTo: (state, action: PayloadAction<string | undefined>) => {
state.to = action.payload state.to = action.payload
}, },
}, },
}) })
export const { redirectTo } = redirectSlice.actions export const { redirectTo } = redirectSlice.actions

View File

@@ -1,45 +1,45 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectTo } from "app/redirect/slice" import { redirectTo } from "app/redirect/slice"
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login"))) export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register"))) export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
export const redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) => export const redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/passwordRecovery")) thunkApi.dispatch(redirectTo("/passwordRecovery"))
) )
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api"))) export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => { export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
const { source } = thunkApi.getState().entries const { source } = thunkApi.getState().entries
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`)) thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
}) })
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) => export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/category/${id}`)) thunkApi.dispatch(redirectTo(`/app/category/${id}`))
) )
export const redirectToRootCategory = createAppAsyncThunk( export const redirectToRootCategory = createAppAsyncThunk(
"redirect/category/root", "redirect/category/root",
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id)) async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
) )
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) => export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`)) thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
) )
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) => export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/feed/${id}`)) thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
) )
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) => export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`)) thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
) )
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`))) export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) => export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`)) thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
) )
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add"))) export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings"))) export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) => export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/admin/users")) thunkApi.dispatch(redirectTo("/app/admin/users"))
) )
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) => export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/admin/metrics")) thunkApi.dispatch(redirectTo("/app/admin/metrics"))
) )
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate"))) export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about"))) export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))

View File

@@ -1,29 +1,29 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
import { reloadServerInfos } from "app/server/thunks" import { reloadServerInfos } from "app/server/thunks"
import { type ServerInfo } from "app/types" import type { ServerInfo } from "app/types"
interface ServerState { interface ServerState {
serverInfos?: ServerInfo serverInfos?: ServerInfo
webSocketConnected: boolean webSocketConnected: boolean
} }
const initialState: ServerState = { const initialState: ServerState = {
webSocketConnected: false, webSocketConnected: false,
} }
export const serverSlice = createSlice({ export const serverSlice = createSlice({
name: "server", name: "server",
initialState, initialState,
reducers: { reducers: {
setWebSocketConnected: (state, action: PayloadAction<boolean>) => { setWebSocketConnected: (state, action: PayloadAction<boolean>) => {
state.webSocketConnected = action.payload state.webSocketConnected = action.payload
}, },
}, },
extraReducers: builder => { extraReducers: builder => {
builder.addCase(reloadServerInfos.fulfilled, (state, action) => { builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
state.serverInfos = action.payload state.serverInfos = action.payload
}) })
}, },
}) })
export const { setWebSocketConnected } = serverSlice.actions export const { setWebSocketConnected } = serverSlice.actions

View File

@@ -1,4 +1,4 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data)) export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))

View File

@@ -1,23 +1,23 @@
import { configureStore } from "@reduxjs/toolkit" import { configureStore } from "@reduxjs/toolkit"
import { entriesSlice } from "app/entries/slice" import { entriesSlice } from "app/entries/slice"
import { redirectSlice } from "app/redirect/slice" import { redirectSlice } from "app/redirect/slice"
import { serverSlice } from "app/server/slice" import { serverSlice } from "app/server/slice"
import { treeSlice } from "app/tree/slice" import { treeSlice } from "app/tree/slice"
import { userSlice } from "app/user/slice" import { userSlice } from "app/user/slice"
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux" import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
export const reducers = { export const reducers = {
entries: entriesSlice.reducer, entries: entriesSlice.reducer,
redirect: redirectSlice.reducer, redirect: redirectSlice.reducer,
tree: treeSlice.reducer, tree: treeSlice.reducer,
server: serverSlice.reducer, server: serverSlice.reducer,
user: userSlice.reducer, user: userSlice.reducer,
} }
export const store = configureStore({ reducer: reducers }) export const store = configureStore({ reducer: reducers })
export type RootState = ReturnType<typeof store.getState> export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

View File

@@ -1,72 +1,68 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
import { markEntry } from "app/entries/thunks" import { markEntry } from "app/entries/thunks"
import { redirectTo } from "app/redirect/slice" import { redirectTo } from "app/redirect/slice"
import { collapseTreeCategory, reloadTree } from "app/tree/thunks" import { collapseTreeCategory, reloadTree } from "app/tree/thunks"
import { type Category } from "app/types" import type { Category } from "app/types"
import { visitCategoryTree } from "app/utils" import { visitCategoryTree } from "app/utils"
interface TreeState { interface TreeState {
rootCategory?: Category rootCategory?: Category
mobileMenuOpen: boolean mobileMenuOpen: boolean
sidebarVisible: boolean sidebarVisible: boolean
} }
const initialState: TreeState = { const initialState: TreeState = {
mobileMenuOpen: false, mobileMenuOpen: false,
sidebarVisible: true, sidebarVisible: true,
} }
export const treeSlice = createSlice({ export const treeSlice = createSlice({
name: "tree", name: "tree",
initialState, initialState,
reducers: { reducers: {
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => { setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
state.mobileMenuOpen = action.payload state.mobileMenuOpen = action.payload
}, },
toggleSidebar: state => { toggleSidebar: state => {
state.sidebarVisible = !state.sidebarVisible state.sidebarVisible = !state.sidebarVisible
}, },
incrementUnreadCount: ( incrementUnreadCount: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
feedId: number feedId: number
amount: number amount: number
}> }>
) => { ) => {
if (!state.rootCategory) return if (!state.rootCategory) return
visitCategoryTree(state.rootCategory, c => visitCategoryTree(state.rootCategory, c => {
c.feeds for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
.filter(f => f.id === action.payload.feedId) f.unread += action.payload.amount
.forEach(f => { }
f.unread += action.payload.amount })
}) },
) },
}, extraReducers: builder => {
}, builder.addCase(reloadTree.fulfilled, (state, action) => {
extraReducers: builder => { state.rootCategory = action.payload
builder.addCase(reloadTree.fulfilled, (state, action) => { })
state.rootCategory = action.payload builder.addCase(collapseTreeCategory.pending, (state, action) => {
}) if (!state.rootCategory) return
builder.addCase(collapseTreeCategory.pending, (state, action) => { visitCategoryTree(state.rootCategory, c => {
if (!state.rootCategory) return if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
visitCategoryTree(state.rootCategory, c => { })
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse })
}) builder.addCase(markEntry.pending, (state, action) => {
}) if (!state.rootCategory) return
builder.addCase(markEntry.pending, (state, action) => { visitCategoryTree(state.rootCategory, c => {
if (!state.rootCategory) return for (const f of c.feeds.filter(f => f.id === +action.meta.arg.entry.feedId)) {
visitCategoryTree(state.rootCategory, c => f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
c.feeds }
.filter(f => f.id === +action.meta.arg.entry.feedId) })
.forEach(f => { })
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1 builder.addCase(redirectTo, state => {
}) state.mobileMenuOpen = false
) })
}) },
builder.addCase(redirectTo, state => { })
state.mobileMenuOpen = false
}) export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions
},
})
export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions

View File

@@ -1,9 +1,9 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
import type { CollapseRequest } from "app/types" import type { CollapseRequest } from "app/types"
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data)) export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
export const collapseTreeCategory = createAppAsyncThunk( export const collapseTreeCategory = createAppAsyncThunk(
"tree/category/collapse", "tree/category/collapse",
async (req: CollapseRequest) => await client.category.collapse(req) async (req: CollapseRequest) => await client.category.collapse(req)
) )

View File

@@ -1,296 +1,296 @@
export type ReadingMode = "all" | "unread" export type ReadingMode = "all" | "unread"
export type ReadingOrder = "asc" | "desc" export type ReadingOrder = "asc" | "desc"
export type ViewMode = "title" | "cozy" | "detailed" | "expanded" export type ViewMode = "title" | "cozy" | "detailed" | "expanded"
export type ScrollMode = "always" | "never" | "if_needed" export type ScrollMode = "always" | "never" | "if_needed"
export type IconDisplayMode = "always" | "never" | "on_desktop" | "on_mobile" export type IconDisplayMode = "always" | "never" | "on_desktop" | "on_mobile"
export interface AddCategoryRequest { export interface AddCategoryRequest {
name: string name: string
parentId?: string parentId?: string
} }
export interface Subscription { export interface Subscription {
id: number id: number
name: string name: string
message?: string message?: string
errorCount: number errorCount: number
lastRefresh?: number lastRefresh?: number
nextRefresh?: number nextRefresh?: number
feedUrl: string feedUrl: string
feedLink: string feedLink: string
iconUrl: string iconUrl: string
unread: number unread: number
categoryId?: string categoryId?: string
position: number position: number
newestItemTime?: number newestItemTime?: number
filter?: string filter?: string
} }
export interface Category { export interface Category {
id: string id: string
parentId?: string parentId?: string
parentName?: string parentName?: string
name: string name: string
children: Category[] children: Category[]
feeds: Subscription[] feeds: Subscription[]
expanded: boolean expanded: boolean
position: number position: number
} }
export interface CategoryModificationRequest { export interface CategoryModificationRequest {
id: number id: number
name?: string name?: string
parentId?: string parentId?: string
position?: number position?: number
} }
export interface CollapseRequest { export interface CollapseRequest {
id: number id: number
collapse: boolean collapse: boolean
} }
export interface Entry { export interface Entry {
id: string id: string
guid: string guid: string
title: string title: string
content: string content: string
categories?: string categories?: string
rtl: boolean rtl: boolean
author?: string author?: string
enclosureUrl?: string enclosureUrl?: string
enclosureType?: string enclosureType?: string
mediaDescription?: string mediaDescription?: string
mediaThumbnailUrl?: string mediaThumbnailUrl?: string
mediaThumbnailWidth?: number mediaThumbnailWidth?: number
mediaThumbnailHeight?: number mediaThumbnailHeight?: number
date: number date: number
insertedDate: number insertedDate: number
feedId: string feedId: string
feedName: string feedName: string
feedUrl: string feedUrl: string
feedLink: string feedLink: string
iconUrl: string iconUrl: string
url: string url: string
read: boolean read: boolean
starred: boolean starred: boolean
markable: boolean markable: boolean
tags: string[] tags: string[]
} }
export interface Entries { export interface Entries {
name: string name: string
message?: string message?: string
errorCount: number errorCount: number
feedLink: string feedLink: string
timestamp: number timestamp: number
hasMore: boolean hasMore: boolean
offset?: number offset?: number
limit?: number limit?: number
entries: Entry[] entries: Entry[]
ignoredReadStatus: boolean ignoredReadStatus: boolean
} }
export interface FeedInfo { export interface FeedInfo {
url: string url: string
title: string title: string
} }
export interface FeedInfoRequest { export interface FeedInfoRequest {
url: string url: string
} }
export interface FeedModificationRequest { export interface FeedModificationRequest {
id: number id: number
name?: string name?: string
categoryId?: string categoryId?: string
position?: number position?: number
filter?: string filter?: string
} }
export interface GetEntriesRequest { export interface GetEntriesRequest {
id: string id: string
readType?: ReadingMode readType?: ReadingMode
newerThan?: number newerThan?: number
order?: ReadingOrder order?: ReadingOrder
keywords?: string keywords?: string
onlyIds?: boolean onlyIds?: boolean
excludedSubscriptionIds?: string excludedSubscriptionIds?: string
tag?: string tag?: string
} }
export interface GetEntriesPaginatedRequest extends GetEntriesRequest { export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
offset: number offset: number
limit: number limit: number
} }
export interface IDRequest { export interface IDRequest {
id: number id: number
} }
export interface LoginRequest { export interface LoginRequest {
name: string name: string
password: string password: string
} }
export interface MarkRequest { export interface MarkRequest {
id: string id: string
read: boolean read: boolean
olderThan?: number olderThan?: number
insertedBefore?: number insertedBefore?: number
keywords?: string keywords?: string
excludedSubscriptions?: number[] excludedSubscriptions?: number[]
} }
export interface MetricCounter { export interface MetricCounter {
count: number count: number
} }
export interface MetricGauge { export interface MetricGauge {
value: number value: number
} }
export interface MetricMeter { export interface MetricMeter {
count: number count: number
m15_rate: number m15_rate: number
m1_rate: number m1_rate: number
m5_rate: number m5_rate: number
mean_rate: number mean_rate: number
units: string units: string
} }
export interface MetricTimer { export interface MetricTimer {
count: number count: number
max: number max: number
mean: number mean: number
min: number min: number
p50: number p50: number
p75: number p75: number
p95: number p95: number
p98: number p98: number
p99: number p99: number
p999: number p999: number
stddev: number stddev: number
m15_rate: number m15_rate: number
m1_rate: number m1_rate: number
m5_rate: number m5_rate: number
mean_rate: number mean_rate: number
duration_units: string duration_units: string
rate_units: string rate_units: string
} }
export interface Metrics { export interface Metrics {
counters: Record<string, MetricCounter> counters: Record<string, MetricCounter>
gauges: Record<string, MetricGauge> gauges: Record<string, MetricGauge>
meters: Record<string, MetricMeter> meters: Record<string, MetricMeter>
timers: Record<string, MetricTimer> timers: Record<string, MetricTimer>
} }
export interface MultipleMarkRequest { export interface MultipleMarkRequest {
requests: MarkRequest[] requests: MarkRequest[]
} }
export interface PasswordResetRequest { export interface PasswordResetRequest {
email: string email: string
} }
export interface ProfileModificationRequest { export interface ProfileModificationRequest {
currentPassword: string currentPassword: string
email: string email: string
newPassword?: string newPassword?: string
newApiKey?: boolean newApiKey?: boolean
} }
export interface RegistrationRequest { export interface RegistrationRequest {
name: string name: string
password: string password: string
email: string email: string
} }
export interface ServerInfo { export interface ServerInfo {
announcement?: string announcement?: string
version: string version: string
gitCommit: string gitCommit: string
allowRegistrations: boolean allowRegistrations: boolean
googleAnalyticsCode?: string googleAnalyticsCode?: string
smtpEnabled: boolean smtpEnabled: boolean
demoAccountEnabled: boolean demoAccountEnabled: boolean
websocketEnabled: boolean websocketEnabled: boolean
websocketPingInterval: number websocketPingInterval: number
treeReloadInterval: number treeReloadInterval: number
} }
export interface SharingSettings { export interface SharingSettings {
email: boolean email: boolean
gmail: boolean gmail: boolean
facebook: boolean facebook: boolean
twitter: boolean twitter: boolean
tumblr: boolean tumblr: boolean
pocket: boolean pocket: boolean
instapaper: boolean instapaper: boolean
buffer: boolean buffer: boolean
} }
export interface Settings { export interface Settings {
language: string language: string
readingMode: ReadingMode readingMode: ReadingMode
readingOrder: ReadingOrder readingOrder: ReadingOrder
showRead: boolean showRead: boolean
scrollMarks: boolean scrollMarks: boolean
customCss?: string customCss?: string
customJs?: string customJs?: string
scrollSpeed: number scrollSpeed: number
scrollMode: ScrollMode scrollMode: ScrollMode
starIconDisplayMode: IconDisplayMode starIconDisplayMode: IconDisplayMode
externalLinkIconDisplayMode: IconDisplayMode externalLinkIconDisplayMode: IconDisplayMode
markAllAsReadConfirmation: boolean markAllAsReadConfirmation: boolean
customContextMenu: boolean customContextMenu: boolean
mobileFooter: boolean mobileFooter: boolean
sharingSettings: SharingSettings sharingSettings: SharingSettings
} }
export interface StarRequest { export interface StarRequest {
id: string id: string
feedId: number feedId: number
starred: boolean starred: boolean
} }
export interface SubscribeRequest { export interface SubscribeRequest {
url: string url: string
title: string title: string
categoryId?: string categoryId?: string
} }
export interface TagRequest { export interface TagRequest {
entryId: number entryId: number
tags: string[] tags: string[]
} }
export interface UserModel { export interface UserModel {
id: number id: number
name: string name: string
email?: string email?: string
apiKey?: string apiKey?: string
password?: string password?: string
enabled: boolean enabled: boolean
created: number created: number
lastLogin?: number lastLogin?: number
admin: boolean admin: boolean
} }
export interface AdminSaveUserRequest { export interface AdminSaveUserRequest {
id?: number id?: number
name: string name: string
email?: string email?: string
password?: string password?: string
enabled: boolean enabled: boolean
admin: boolean admin: boolean
} }
export interface AuthenticationError { export interface AuthenticationError {
message: string message: string
allowRegistrations: boolean allowRegistrations: boolean
} }

View File

@@ -1,120 +1,120 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { showNotification } from "@mantine/notifications" import { showNotification } from "@mantine/notifications"
import { createSlice, isAnyOf } from "@reduxjs/toolkit" import { createSlice, isAnyOf } from "@reduxjs/toolkit"
import { type Settings, type UserModel } from "app/types" import type { Settings, UserModel } from "app/types"
import { import {
changeCustomContextMenu, changeCustomContextMenu,
changeExternalLinkIconDisplayMode, changeExternalLinkIconDisplayMode,
changeLanguage, changeLanguage,
changeMarkAllAsReadConfirmation, changeMarkAllAsReadConfirmation,
changeMobileFooter, changeMobileFooter,
changeReadingMode, changeReadingMode,
changeReadingOrder, changeReadingOrder,
changeScrollMarks, changeScrollMarks,
changeScrollMode, changeScrollMode,
changeScrollSpeed, changeScrollSpeed,
changeSharingSetting, changeSharingSetting,
changeShowRead, changeShowRead,
changeStarIconDisplayMode, changeStarIconDisplayMode,
reloadProfile, reloadProfile,
reloadSettings, reloadSettings,
reloadTags, reloadTags,
} from "./thunks" } from "./thunks"
interface UserState { interface UserState {
settings?: Settings settings?: Settings
profile?: UserModel profile?: UserModel
tags?: string[] tags?: string[]
} }
const initialState: UserState = {} const initialState: UserState = {}
export const userSlice = createSlice({ export const userSlice = createSlice({
name: "user", name: "user",
initialState, initialState,
reducers: {}, reducers: {},
extraReducers: builder => { extraReducers: builder => {
builder.addCase(reloadSettings.fulfilled, (state, action) => { builder.addCase(reloadSettings.fulfilled, (state, action) => {
state.settings = action.payload state.settings = action.payload
}) })
builder.addCase(reloadProfile.fulfilled, (state, action) => { builder.addCase(reloadProfile.fulfilled, (state, action) => {
state.profile = action.payload state.profile = action.payload
}) })
builder.addCase(reloadTags.fulfilled, (state, action) => { builder.addCase(reloadTags.fulfilled, (state, action) => {
state.tags = action.payload state.tags = action.payload
}) })
builder.addCase(changeReadingMode.pending, (state, action) => { builder.addCase(changeReadingMode.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.readingMode = action.meta.arg state.settings.readingMode = action.meta.arg
}) })
builder.addCase(changeReadingOrder.pending, (state, action) => { builder.addCase(changeReadingOrder.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.readingOrder = action.meta.arg state.settings.readingOrder = action.meta.arg
}) })
builder.addCase(changeLanguage.pending, (state, action) => { builder.addCase(changeLanguage.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.language = action.meta.arg state.settings.language = action.meta.arg
}) })
builder.addCase(changeScrollSpeed.pending, (state, action) => { builder.addCase(changeScrollSpeed.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.scrollSpeed = action.meta.arg ? 400 : 0 state.settings.scrollSpeed = action.meta.arg ? 400 : 0
}) })
builder.addCase(changeShowRead.pending, (state, action) => { builder.addCase(changeShowRead.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.showRead = action.meta.arg state.settings.showRead = action.meta.arg
}) })
builder.addCase(changeScrollMarks.pending, (state, action) => { builder.addCase(changeScrollMarks.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.scrollMarks = action.meta.arg state.settings.scrollMarks = action.meta.arg
}) })
builder.addCase(changeScrollMode.pending, (state, action) => { builder.addCase(changeScrollMode.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.scrollMode = action.meta.arg state.settings.scrollMode = action.meta.arg
}) })
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => { builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.starIconDisplayMode = action.meta.arg state.settings.starIconDisplayMode = action.meta.arg
}) })
builder.addCase(changeExternalLinkIconDisplayMode.pending, (state, action) => { builder.addCase(changeExternalLinkIconDisplayMode.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.externalLinkIconDisplayMode = action.meta.arg state.settings.externalLinkIconDisplayMode = action.meta.arg
}) })
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => { builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.markAllAsReadConfirmation = action.meta.arg state.settings.markAllAsReadConfirmation = action.meta.arg
}) })
builder.addCase(changeCustomContextMenu.pending, (state, action) => { builder.addCase(changeCustomContextMenu.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.customContextMenu = action.meta.arg state.settings.customContextMenu = action.meta.arg
}) })
builder.addCase(changeMobileFooter.pending, (state, action) => { builder.addCase(changeMobileFooter.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.mobileFooter = action.meta.arg state.settings.mobileFooter = action.meta.arg
}) })
builder.addCase(changeSharingSetting.pending, (state, action) => { builder.addCase(changeSharingSetting.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
}) })
builder.addMatcher( builder.addMatcher(
isAnyOf( isAnyOf(
changeLanguage.fulfilled, changeLanguage.fulfilled,
changeScrollSpeed.fulfilled, changeScrollSpeed.fulfilled,
changeShowRead.fulfilled, changeShowRead.fulfilled,
changeScrollMarks.fulfilled, changeScrollMarks.fulfilled,
changeScrollMode.fulfilled, changeScrollMode.fulfilled,
changeStarIconDisplayMode.fulfilled, changeStarIconDisplayMode.fulfilled,
changeExternalLinkIconDisplayMode.fulfilled, changeExternalLinkIconDisplayMode.fulfilled,
changeMarkAllAsReadConfirmation.fulfilled, changeMarkAllAsReadConfirmation.fulfilled,
changeCustomContextMenu.fulfilled, changeCustomContextMenu.fulfilled,
changeMobileFooter.fulfilled, changeMobileFooter.fulfilled,
changeSharingSetting.fulfilled changeSharingSetting.fulfilled
), ),
() => { () => {
showNotification({ showNotification({
message: t`Settings saved.`, message: t`Settings saved.`,
color: "green", color: "green",
}) })
} }
) )
}, },
}) })

View File

@@ -1,99 +1,99 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
import { reloadEntries } from "app/entries/thunks" import { reloadEntries } from "app/entries/thunks"
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types" import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data)) export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data)) export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data)) export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => { export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, readingMode }) client.user.saveSettings({ ...settings, readingMode })
thunkApi.dispatch(reloadEntries()) thunkApi.dispatch(reloadEntries())
}) })
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => { export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, readingOrder }) client.user.saveSettings({ ...settings, readingOrder })
thunkApi.dispatch(reloadEntries()) thunkApi.dispatch(reloadEntries())
}) })
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => { export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, language }) client.user.saveSettings({ ...settings, language })
}) })
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => { export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 }) client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
}) })
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => { export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, showRead }) client.user.saveSettings({ ...settings, showRead })
}) })
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => { export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, scrollMarks }) client.user.saveSettings({ ...settings, scrollMarks })
}) })
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => { export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, scrollMode }) client.user.saveSettings({ ...settings, scrollMode })
}) })
export const changeStarIconDisplayMode = createAppAsyncThunk( export const changeStarIconDisplayMode = createAppAsyncThunk(
"settings/starIconDisplayMode", "settings/starIconDisplayMode",
(starIconDisplayMode: IconDisplayMode, thunkApi) => { (starIconDisplayMode: IconDisplayMode, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, starIconDisplayMode }) client.user.saveSettings({ ...settings, starIconDisplayMode })
} }
) )
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk( export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
"settings/externalLinkIconDisplayMode", "settings/externalLinkIconDisplayMode",
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => { (externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode }) client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
} }
) )
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk( export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
"settings/markAllAsReadConfirmation", "settings/markAllAsReadConfirmation",
(markAllAsReadConfirmation: boolean, thunkApi) => { (markAllAsReadConfirmation: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, markAllAsReadConfirmation }) client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
} }
) )
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => { export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, customContextMenu }) client.user.saveSettings({ ...settings, customContextMenu })
}) })
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => { export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, mobileFooter }) client.user.saveSettings({ ...settings, mobileFooter })
}) })
export const changeSharingSetting = createAppAsyncThunk( export const changeSharingSetting = createAppAsyncThunk(
"settings/sharingSetting", "settings/sharingSetting",
( (
sharingSetting: { sharingSetting: {
site: keyof SharingSettings site: keyof SharingSettings
value: boolean value: boolean
}, },
thunkApi thunkApi
) => { ) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ client.user.saveSettings({
...settings, ...settings,
sharingSettings: { sharingSettings: {
...settings.sharingSettings, ...settings.sharingSettings,
[sharingSetting.site]: sharingSetting.value, [sharingSetting.site]: sharingSetting.value,
}, },
}) })
} }
) )

View File

@@ -1,47 +1,49 @@
import { throttle } from "throttle-debounce" import { throttle } from "throttle-debounce"
import { type Category } from "./types" import type { Category } from "./types"
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void { export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
visitor(category) visitor(category)
category.children.forEach(child => visitCategoryTree(child, visitor)) for (const child of category.children) {
} visitCategoryTree(child, visitor)
}
export function flattenCategoryTree(category: Category): Category[] { }
const categories: Category[] = []
visitCategoryTree(category, c => categories.push(c)) export function flattenCategoryTree(category: Category): Category[] {
return categories const categories: Category[] = []
} visitCategoryTree(category, c => categories.push(c))
return categories
export function categoryUnreadCount(category?: Category): number { }
if (!category) return 0
export function categoryUnreadCount(category?: Category): number {
return flattenCategoryTree(category) if (!category) return 0
.flatMap(c => c.feeds)
.map(f => f.unread) return flattenCategoryTree(category)
.reduce((total, current) => total + current, 0) .flatMap(c => c.feeds)
} .map(f => f.unread)
.reduce((total, current) => total + current, 0)
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => { }
const placeholderWidth = width && Math.min(width, maxWidth)
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
return { width: placeholderWidth, height: placeholderHeight } const placeholderWidth = width && Math.min(width, maxWidth)
} const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
return { width: placeholderWidth, height: placeholderHeight }
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => { }
const offset = (options.top ?? 0).toFixed()
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
const onScroll = throttle(100, () => { const offset = (options.top ?? 0).toFixed()
if (window.scrollY.toFixed() === offset) {
window.removeEventListener("scroll", onScroll) const onScroll = throttle(100, () => {
onScrollEnded() if (window.scrollY.toFixed() === offset) {
} window.removeEventListener("scroll", onScroll)
}) onScrollEnded()
window.addEventListener("scroll", onScroll) }
})
// scrollTo does not trigger if there's nothing to do, trigger it manually window.addEventListener("scroll", onScroll)
onScroll()
// scrollTo does not trigger if there's nothing to do, trigger it manually
window.scrollTo(options) onScroll()
}
window.scrollTo(options)
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str) }
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str)

View File

@@ -1,37 +1,37 @@
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core" import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
import { type ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon" import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { useActionButton } from "hooks/useActionButton" import { useActionButton } from "hooks/useActionButton"
import { forwardRef, type MouseEventHandler, type ReactNode } from "react" import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
interface ActionButtonProps { interface ActionButtonProps {
className?: string className?: string
icon?: ReactNode icon?: ReactNode
label: ReactNode label: ReactNode
onClick?: MouseEventHandler onClick?: MouseEventHandler
variant?: ActionIconVariant & ButtonVariant variant?: ActionIconVariant & ButtonVariant
hideLabelOnDesktop?: boolean hideLabelOnDesktop?: boolean
showLabelOnMobile?: boolean showLabelOnMobile?: boolean
} }
/** /**
* Switches between Button with label (desktop) and ActionIcon (mobile) * Switches between Button with label (desktop) and ActionIcon (mobile)
*/ */
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => { export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
const { mobile } = useActionButton() const { mobile } = useActionButton()
const theme = useMantineTheme() const theme = useMantineTheme()
const variant = props.variant ?? "subtle" const variant = props.variant ?? "subtle"
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop) const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
return iconOnly ? ( return iconOnly ? (
<Tooltip label={props.label} openDelay={Constants.tooltip.delay}> <Tooltip label={props.label} openDelay={Constants.tooltip.delay}>
<ActionIcon ref={ref} color={theme.primaryColor} variant={variant} className={props.className} onClick={props.onClick}> <ActionIcon ref={ref} color={theme.primaryColor} variant={variant} className={props.className} onClick={props.onClick}>
{props.icon} {props.icon}
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
) : ( ) : (
<Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}> <Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}>
{props.label} {props.label}
</Button> </Button>
) )
}) })
ActionButton.displayName = "HeaderButton" ActionButton.displayName = "HeaderButton"

View File

@@ -1,47 +1,47 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Alert as MantineAlert, Box } from "@mantine/core" import { Box, Alert as MantineAlert } from "@mantine/core"
import { Fragment } from "react" import { Fragment } from "react"
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb" import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
type Level = "error" | "warning" | "success" type Level = "error" | "warning" | "success"
export interface ErrorsAlertProps { export interface ErrorsAlertProps {
level?: Level level?: Level
messages: string[] messages: string[]
} }
export function Alert(props: ErrorsAlertProps) { export function Alert(props: ErrorsAlertProps) {
let title: React.ReactNode let title: React.ReactNode
let color: string let color: string
let icon: React.ReactNode let icon: React.ReactNode
const level = props.level ?? "error" const level = props.level ?? "error"
switch (level) { switch (level) {
case "error": case "error":
title = <Trans>Error</Trans> title = <Trans>Error</Trans>
color = "red" color = "red"
icon = <TbAlertCircle /> icon = <TbAlertCircle />
break break
case "warning": case "warning":
title = <Trans>Warning</Trans> title = <Trans>Warning</Trans>
color = "orange" color = "orange"
icon = <TbAlertTriangle /> icon = <TbAlertTriangle />
break break
case "success": case "success":
title = <Trans>Success</Trans> title = <Trans>Success</Trans>
color = "green" color = "green"
icon = <TbCircleCheck /> icon = <TbCircleCheck />
break break
} }
return ( return (
<MantineAlert title={title} color={color} icon={icon}> <MantineAlert title={title} color={color} icon={icon}>
{props.messages.map((m, i) => ( {props.messages.map((m, i) => (
<Fragment key={m}> <Fragment key={m}>
<Box>{m}</Box> <Box>{m}</Box>
{i !== props.messages.length - 1 && <br />} {i !== props.messages.length - 1 && <br />}
</Fragment> </Fragment>
))} ))}
</MantineAlert> </MantineAlert>
) )
} }

View File

@@ -1,15 +1,15 @@
import { Helmet } from "react-helmet" import { Helmet } from "react-helmet"
export const DisablePullToRefresh = () => { export const DisablePullToRefresh = () => {
return ( return (
<Helmet> <Helmet>
<style type="text/css"> <style type="text/css">
{` {`
html, body { html, body {
overscroll-behavior: none; overscroll-behavior: none;
} }
`} `}
</style> </style>
</Helmet> </Helmet>
) )
} }

View File

@@ -1,26 +1,26 @@
import { ErrorPage } from "pages/ErrorPage" import { ErrorPage } from "pages/ErrorPage"
import React, { type ReactNode } from "react" import React, { type ReactNode } from "react"
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children?: ReactNode children?: ReactNode
} }
interface ErrorBoundaryState { interface ErrorBoundaryState {
error?: Error error?: Error
} }
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) { constructor(props: ErrorBoundaryProps) {
super(props) super(props)
this.state = {} this.state = {}
} }
componentDidCatch(error: Error) { componentDidCatch(error: Error) {
this.setState({ error }) this.setState({ error })
} }
render() { render() {
if (this.state.error) return <ErrorPage error={this.state.error} /> if (this.state.error) return <ErrorPage error={this.state.error} />
return this.props.children return this.props.children
} }
} }

View File

@@ -1,75 +1,75 @@
import { Box, Center } from "@mantine/core" import { Box, Center } from "@mantine/core"
import { useState } from "react" import { useState } from "react"
import { TbPhoto } from "react-icons/tb" import { TbPhoto } from "react-icons/tb"
import { tss } from "tss" import { tss } from "tss"
interface ImageWithPlaceholderWhileLoadingProps { interface ImageWithPlaceholderWhileLoadingProps {
src: string src: string
alt: string alt: string
title?: string title?: string
width?: number width?: number
height?: number | "auto" height?: number | "auto"
placeholderWidth?: number placeholderWidth?: number
placeholderHeight?: number placeholderHeight?: number
placeholderBackgroundColor?: string placeholderBackgroundColor?: string
placeholderIconSize?: number placeholderIconSize?: number
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
placeholderWidth?: number placeholderWidth?: number
placeholderHeight?: number placeholderHeight?: number
placeholderBackgroundColor?: string placeholderBackgroundColor?: string
}>() }>()
.create(props => ({ .create(props => ({
placeholder: { placeholder: {
width: props.placeholderWidth ?? 400, width: props.placeholderWidth ?? 400,
height: props.placeholderHeight ?? 600, height: props.placeholderHeight ?? 600,
maxWidth: "100%", maxWidth: "100%",
backgroundColor: backgroundColor:
props.placeholderBackgroundColor ?? props.placeholderBackgroundColor ??
(props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]), (props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]),
}, },
})) }))
export function ImageWithPlaceholderWhileLoading({ export function ImageWithPlaceholderWhileLoading({
alt, alt,
height, height,
placeholderBackgroundColor, placeholderBackgroundColor,
placeholderHeight, placeholderHeight,
placeholderIconSize, placeholderIconSize,
placeholderWidth, placeholderWidth,
src, src,
title, title,
width, width,
}: ImageWithPlaceholderWhileLoadingProps) { }: ImageWithPlaceholderWhileLoadingProps) {
const { classes } = useStyles({ const { classes } = useStyles({
placeholderWidth, placeholderWidth,
placeholderHeight, placeholderHeight,
placeholderBackgroundColor, placeholderBackgroundColor,
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
return ( return (
<> <>
{loading && ( {loading && (
<Box> <Box>
<Center className={classes.placeholder}> <Center className={classes.placeholder}>
<div> <div>
<TbPhoto size={placeholderIconSize ?? 48} /> <TbPhoto size={placeholderIconSize ?? 48} />
</div> </div>
</Center> </Center>
</Box> </Box>
)} )}
<img <img
src={src} src={src}
alt={alt} alt={alt}
title={title} title={title}
width={width} width={width}
height={height} height={height}
onLoad={() => setLoading(false)} onLoad={() => setLoading(false)}
style={{ display: loading ? "none" : "block" }} style={{ display: loading ? "none" : "block" }}
/> />
</> </>
) )
} }

View File

@@ -1,224 +1,224 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core" import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
import { useOs } from "@mantine/hooks" import { useOs } from "@mantine/hooks"
import { Constants } from "app/constants" import { Constants } from "app/constants"
export function KeyboardShortcutsHelp() { export function KeyboardShortcutsHelp() {
const isMacOS = useOs() === "macos" const isMacOS = useOs() === "macos"
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Refresh</Trans> <Trans>Refresh</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>R</Kbd> <Kbd>R</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Open next entry</Trans> <Trans>Open next entry</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>J</Kbd> <Kbd>J</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Open previous entry</Trans> <Trans>Open previous entry</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>K</Kbd> <Kbd>K</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Set focus on next entry without opening it</Trans> <Trans>Set focus on next entry without opening it</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>N</Kbd> <Kbd>N</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Set focus on previous entry without opening it</Trans> <Trans>Set focus on previous entry without opening it</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>P</Kbd> <Kbd>P</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Move the page down</Trans> <Trans>Move the page down</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Space</Trans> <Trans>Space</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Move the page up</Trans> <Trans>Move the page up</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Shift</Trans> <Trans>Shift</Trans>
</Kbd> </Kbd>
<span> + </span> <span> + </span>
<Kbd> <Kbd>
<Trans>Space</Trans> <Trans>Space</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Open/close current entry</Trans> <Trans>Open/close current entry</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>O</Kbd> <Kbd>O</Kbd>
<span>, </span> <span>, </span>
<Kbd> <Kbd>
<Trans>Enter</Trans> <Trans>Enter</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Open current entry in a new tab</Trans> <Trans>Open current entry in a new tab</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>V</Kbd> <Kbd>V</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Open current entry in a new tab in the background</Trans> <Trans>Open current entry in a new tab in the background</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>B</Kbd> <Kbd>B</Kbd>
<span>*, </span> <span>*, </span>
<Kbd> <Kbd>
<Trans>Middle click</Trans> <Trans>Middle click</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Toggle read status of current entry</Trans> <Trans>Toggle read status of current entry</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>M</Kbd> <Kbd>M</Kbd>
<span>, </span> <span>, </span>
<Trans>Swipe header to the left</Trans> <Trans>Swipe header to the left</Trans>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Toggle starred status of current entry</Trans> <Trans>Toggle starred status of current entry</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>S</Kbd> <Kbd>S</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Mark all entries as read</Trans> <Trans>Mark all entries as read</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Shift</Trans> <Trans>Shift</Trans>
</Kbd> </Kbd>
<span> + </span> <span> + </span>
<Kbd>A</Kbd> <Kbd>A</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Go to the All view</Trans> <Trans>Go to the All view</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>G</Kbd> <Kbd>G</Kbd>
<span> </span> <span> </span>
<Kbd>A</Kbd> <Kbd>A</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Navigate to a subscription by entering its name</Trans> <Trans>Navigate to a subscription by entering its name</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>{isMacOS ? "Cmd" : "Ctrl"}</Trans> <Trans>{isMacOS ? "Cmd" : "Ctrl"}</Trans>
</Kbd> </Kbd>
<span> + </span> <span> + </span>
<Kbd>K</Kbd> <Kbd>K</Kbd>
<span>, </span> <span>, </span>
<Kbd>G</Kbd> <Kbd>G</Kbd>
<span> </span> <span> </span>
<Kbd>U</Kbd> <Kbd>U</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show entry menu (desktop)</Trans> <Trans>Show entry menu (desktop)</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Right click</Trans> <Trans>Right click</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show native menu (desktop)</Trans> <Trans>Show native menu (desktop)</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Shift</Trans> <Trans>Shift</Trans>
</Kbd> </Kbd>
<span> + </span> <span> + </span>
<Kbd> <Kbd>
<Trans>Right click</Trans> <Trans>Right click</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show entry menu (mobile)</Trans> <Trans>Show entry menu (mobile)</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Long press</Trans> <Trans>Long press</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Toggle sidebar</Trans> <Trans>Toggle sidebar</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>F</Kbd> <Kbd>F</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show keyboard shortcut help</Trans> <Trans>Show keyboard shortcut help</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>?</Kbd> <Kbd>?</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<Box> <Box>
<span>* </span> <span>* </span>
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer"> <Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
<Trans>Browser extension required for Chrome</Trans> <Trans>Browser extension required for Chrome</Trans>
</Anchor> </Anchor>
</Box> </Box>
</Stack> </Stack>
) )
} }

View File

@@ -1,9 +1,9 @@
import { Center, Loader as MantineLoader } from "@mantine/core" import { Center, Loader as MantineLoader } from "@mantine/core"
export function Loader() { export function Loader() {
return ( return (
<Center> <Center>
<MantineLoader size="lg" type="bars" /> <MantineLoader size="lg" type="bars" />
</Center> </Center>
) )
} }

View File

@@ -1,10 +1,10 @@
import { Image } from "@mantine/core" import { Image } from "@mantine/core"
import logo from "assets/logo.svg" import logo from "assets/logo.svg"
export interface LogoProps { export interface LogoProps {
size: number size: number
} }
export function Logo(props: LogoProps) { export function Logo(props: LogoProps) {
return <Image src={logo} w={props.size} /> return <Image src={logo} w={props.size} />
} }

View File

@@ -1,21 +1,21 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Tooltip } from "@mantine/core" import { Tooltip } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import dayjs from "dayjs" import dayjs from "dayjs"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
export function RelativeDate(props: { date: Date | number | undefined }) { export function RelativeDate(props: { date: Date | number | undefined }) {
const [now, setNow] = useState(new Date()) const [now, setNow] = useState(new Date())
useEffect(() => { useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60 * 1000) const interval = setInterval(() => setNow(new Date()), 60 * 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
if (!props.date) return <Trans>N/A</Trans> if (!props.date) return <Trans>N/A</Trans>
const date = dayjs(props.date) const date = dayjs(props.date)
return ( return (
<Tooltip label={date.toDate().toLocaleString()} openDelay={Constants.tooltip.delay}> <Tooltip label={date.toDate().toLocaleString()} openDelay={Constants.tooltip.delay}>
<span>{date.from(dayjs(now))}</span> <span>{date.from(dayjs(now))}</span>
</Tooltip> </Tooltip>
) )
} }

View File

@@ -1,54 +1,54 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core" import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { type AdminSaveUserRequest, type UserModel } from "app/types" import type { AdminSaveUserRequest, UserModel } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy } from "react-icons/tb" import { TbDeviceFloppy } from "react-icons/tb"
interface UserEditProps { interface UserEditProps {
user?: UserModel user?: UserModel
onCancel: () => void onCancel: () => void
onSave: () => void onSave: () => void
} }
export function UserEdit(props: UserEditProps) { export function UserEdit(props: UserEditProps) {
const form = useForm<AdminSaveUserRequest>({ const form = useForm<AdminSaveUserRequest>({
initialValues: props.user ?? { initialValues: props.user ?? {
name: "", name: "",
enabled: true, enabled: true,
admin: false, admin: false,
}, },
}) })
const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave }) const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave })
return ( return (
<> <>
{saveUser.error && ( {saveUser.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(saveUser.error)} /> <Alert messages={errorToStrings(saveUser.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(saveUser.execute)}> <form onSubmit={form.onSubmit(saveUser.execute)}>
<Stack> <Stack>
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required /> <TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} /> <PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} /> <TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} /> <Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} /> <Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
<Group justify="right"> <Group justify="right">
<Button variant="default" onClick={props.onCancel}> <Button variant="default" onClick={props.onCancel}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}> <Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</> </>
) )
} }

View File

@@ -1,36 +1,36 @@
import { Input, Textarea } from "@mantine/core" import { Input, Textarea } from "@mantine/core"
import RichCodeEditor from "components/code/RichCodeEditor" import RichCodeEditor from "components/code/RichCodeEditor"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { type ReactNode } from "react" import type { ReactNode } from "react"
interface CodeEditorProps { interface CodeEditorProps {
description?: ReactNode description?: ReactNode
language: "css" | "javascript" language: "css" | "javascript"
value?: string value?: string
onChange: (value: string | undefined) => void onChange: (value: string | undefined) => void
} }
export function CodeEditor(props: CodeEditorProps) { export function CodeEditor(props: CodeEditorProps) {
const mobile = useMobile() const mobile = useMobile()
return mobile ? ( return mobile ? (
// monaco mobile support is poor, fallback to textarea // monaco mobile support is poor, fallback to textarea
<Textarea <Textarea
autosize autosize
minRows={4} minRows={4}
maxRows={15} maxRows={15}
description={props.description} description={props.description}
styles={{ styles={{
input: { input: {
fontFamily: "monospace", fontFamily: "monospace",
}, },
}} }}
value={props.value} value={props.value}
onChange={e => props.onChange(e.currentTarget.value)} onChange={e => props.onChange(e.currentTarget.value)}
/> />
) : ( ) : (
<Input.Wrapper description={props.description}> <Input.Wrapper description={props.description}>
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} /> <RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
</Input.Wrapper> </Input.Wrapper>
) )
} }

View File

@@ -1,52 +1,51 @@
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { useColorScheme } from "hooks/useColorScheme" import { useColorScheme } from "hooks/useColorScheme"
import { useAsync } from "react-async-hook" import { useAsync } from "react-async-hook"
const init = async () => { const init = async () => {
window.MonacoEnvironment = { window.MonacoEnvironment = {
async getWorker(_, label) { async getWorker(_, label) {
let worker let worker: typeof import("*?worker")
if (label === "css") { if (label === "css") {
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker") worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
} else if (label === "javascript") { } else if (label === "javascript") {
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker") worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
} else { } else {
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker") worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
} }
// eslint-disable-next-line new-cap return new worker.default()
return new worker.default() },
}, }
}
const monacoReact = await import("@monaco-editor/react")
const monacoReact = await import("@monaco-editor/react") const monaco = await import("monaco-editor")
const monaco = await import("monaco-editor") monacoReact.loader.config({ monaco })
monacoReact.loader.config({ monaco }) return monacoReact.Editor
return monacoReact.Editor }
}
interface RichCodeEditorProps {
interface RichCodeEditorProps { height: number | string
height: number | string language: "css" | "javascript"
language: "css" | "javascript" value?: string
value?: string onChange: (value: string | undefined) => void
onChange: (value: string | undefined) => void }
}
function RichCodeEditor(props: RichCodeEditorProps) {
function RichCodeEditor(props: RichCodeEditorProps) { const colorScheme = useColorScheme()
const colorScheme = useColorScheme() const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
const { result: Editor } = useAsync(init, [])
const { result: Editor } = useAsync(init, []) if (!Editor) return <Loader />
if (!Editor) return <Loader /> return (
return ( <Editor
<Editor height={props.height}
height={props.height} defaultLanguage={props.language}
defaultLanguage={props.language} theme={editorTheme}
theme={editorTheme} options={{ minimap: { enabled: false } }}
options={{ minimap: { enabled: false } }} value={props.value}
value={props.value} onChange={props.onChange}
onChange={props.onChange} />
/> )
) }
}
export default RichCodeEditor
export default RichCodeEditor

View File

@@ -1,11 +1,11 @@
import { TypographyStylesProvider } from "@mantine/core" import { TypographyStylesProvider } from "@mantine/core"
import { type ReactNode } from "react" import type { ReactNode } from "react"
/** /**
* This component is used to provide basic styles to html typography elements. * This component is used to provide basic styles to html typography elements.
* *
* see https://mantine.dev/core/typography-styles-provider/ * see https://mantine.dev/core/typography-styles-provider/
*/ */
export const BasicHtmlStyles = (props: { children: ReactNode }) => { export const BasicHtmlStyles = (props: { children: ReactNode }) => {
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider> return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
} }

View File

@@ -1,103 +1,103 @@
import { Box, Mark } from "@mantine/core" import { Box, Mark } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { calculatePlaceholderSize } from "app/utils" import { calculatePlaceholderSize } from "app/utils"
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
import escapeStringRegexp from "escape-string-regexp" import escapeStringRegexp from "escape-string-regexp"
import { type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave" import { type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
import React from "react" import React from "react"
import { tss } from "tss" import { tss } from "tss"
export interface ContentProps { export interface ContentProps {
content: string content: string
highlight?: string highlight?: string
} }
const useStyles = tss.create(() => ({ const useStyles = tss.create(() => ({
content: { content: {
// break long links or long words // break long links or long words
overflowWrap: "anywhere", overflowWrap: "anywhere",
"& a": { "& a": {
color: "inherit", color: "inherit",
textDecoration: "underline", textDecoration: "underline",
}, },
"& iframe": { "& iframe": {
maxWidth: "100%", maxWidth: "100%",
}, },
"& pre, & code": { "& pre, & code": {
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}, },
}, },
})) }))
const transform: TransformCallback = node => { const transform: TransformCallback = node => {
if (node.tagName === "IMG") { if (node.tagName === "IMG") {
// show placeholders for loading img tags, this allows the entry to have its final height immediately // show placeholders for loading img tags, this allows the entry to have its final height immediately
const src = node.getAttribute("src") ?? undefined const src = node.getAttribute("src") ?? undefined
if (!src) return undefined if (!src) return undefined
const alt = node.getAttribute("alt") ?? "image" const alt = node.getAttribute("alt") ?? "image"
const title = node.getAttribute("title") ?? undefined const title = node.getAttribute("title") ?? undefined
const nodeWidth = node.getAttribute("width") const nodeWidth = node.getAttribute("width")
const nodeHeight = node.getAttribute("height") const nodeHeight = node.getAttribute("height")
const width = nodeWidth ? parseInt(nodeWidth, 10) : undefined const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
const height = nodeHeight ? parseInt(nodeHeight, 10) : undefined const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
const placeholderSize = calculatePlaceholderSize({ const placeholderSize = calculatePlaceholderSize({
width, width,
height, height,
maxWidth: Constants.layout.entryMaxWidth, maxWidth: Constants.layout.entryMaxWidth,
}) })
return ( return (
<ImageWithPlaceholderWhileLoading <ImageWithPlaceholderWhileLoading
src={src} src={src}
alt={alt} alt={alt}
title={title} title={title}
width={width} width={width}
height="auto" height="auto"
placeholderWidth={placeholderSize.width} placeholderWidth={placeholderSize.width}
placeholderHeight={placeholderSize.height} placeholderHeight={placeholderSize.height}
/> />
) )
} }
return undefined return undefined
} }
class HighlightMatcher extends Matcher { class HighlightMatcher extends Matcher {
private readonly search: string private readonly search: string
constructor(search: string) { constructor(search: string) {
super("highlight") super("highlight")
this.search = escapeStringRegexp(search) this.search = escapeStringRegexp(search)
} }
match(string: string): MatchResponse<unknown> | null { match(string: string): MatchResponse<unknown> | null {
const pattern = this.search.split(" ").join("|") const pattern = this.search.split(" ").join("|")
return this.doMatch(string, new RegExp(pattern, "i"), () => ({})) return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
} }
replaceWith(children: ChildrenNode): Node { replaceWith(children: ChildrenNode): Node {
return <Mark>{children}</Mark> return <Mark>{children}</Mark>
} }
asTag(): string { asTag(): string {
return "span" return "span"
} }
} }
// memoize component because Interweave is costly // memoize component because Interweave is costly
const Content = React.memo((props: ContentProps) => { const Content = React.memo((props: ContentProps) => {
const { classes } = useStyles() const { classes } = useStyles()
const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : [] const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : []
return ( return (
<BasicHtmlStyles> <BasicHtmlStyles>
<Box className={classes.content}> <Box className={classes.content}>
<Interweave content={props.content} transform={transform} matchers={matchers} /> <Interweave content={props.content} transform={transform} matchers={matchers} />
</Box> </Box>
</BasicHtmlStyles> </BasicHtmlStyles>
) )
}) })
Content.displayName = "Content" Content.displayName = "Content"
export { Content } export { Content }

View File

@@ -1,24 +1,29 @@
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
export function Enclosure(props: { enclosureType: string; enclosureUrl: string }) { export function Enclosure(props: {
const hasVideo = props.enclosureType.startsWith("video") enclosureType: string
const hasAudio = props.enclosureType.startsWith("audio") enclosureUrl: string
const hasImage = props.enclosureType.startsWith("image") }) {
const hasVideo = props.enclosureType.startsWith("video")
return ( const hasAudio = props.enclosureType.startsWith("audio")
<BasicHtmlStyles> const hasImage = props.enclosureType.startsWith("image")
{hasVideo && (
<video controls width="100%"> return (
<source src={props.enclosureUrl} type={props.enclosureType} /> <BasicHtmlStyles>
</video> {hasVideo && (
)} // biome-ignore lint/a11y/useMediaCaption: we don't have any captions for videos
{hasAudio && ( <video controls width="100%">
<audio controls> <source src={props.enclosureUrl} type={props.enclosureType} />
<source src={props.enclosureUrl} type={props.enclosureType} /> </video>
</audio> )}
)} {hasAudio && (
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />} // biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
</BasicHtmlStyles> <audio controls>
) <source src={props.enclosureUrl} type={props.enclosureType} />
} </audio>
)}
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
</BasicHtmlStyles>
)
}

View File

@@ -1,329 +1,329 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { openModal } from "@mantine/modals" import { openModal } from "@mantine/modals"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { type ExpendableEntry } from "app/entries/slice" import type { ExpendableEntry } from "app/entries/slice"
import { import {
loadMoreEntries, loadMoreEntries,
markAllEntries, markAllEntries,
markEntry, markEntry,
reloadEntries, reloadEntries,
selectEntry, selectEntry,
selectNextEntry, selectNextEntry,
selectPreviousEntry, selectPreviousEntry,
starEntry, starEntry,
} from "app/entries/thunks" } from "app/entries/thunks"
import { redirectToRootCategory } from "app/redirect/thunks" import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { toggleSidebar } from "app/tree/slice" import { toggleSidebar } from "app/tree/slice"
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp" import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMousetrap } from "hooks/useMousetrap" import { useMousetrap } from "hooks/useMousetrap"
import { useViewMode } from "hooks/useViewMode" import { useViewMode } from "hooks/useViewMode"
import { useEffect } from "react" import { useEffect } from "react"
import { useContextMenu } from "react-contexify" import { useContextMenu } from "react-contexify"
import InfiniteScroll from "react-infinite-scroller" import InfiniteScroll from "react-infinite-scroller"
import { throttle } from "throttle-debounce" import { throttle } from "throttle-debounce"
import { FeedEntry } from "./FeedEntry" import { FeedEntry } from "./FeedEntry"
export function FeedEntries() { export function FeedEntries() {
const source = useAppSelector(state => state.entries.source) const source = useAppSelector(state => state.entries.source)
const entries = useAppSelector(state => state.entries.entries) const entries = useAppSelector(state => state.entries.entries)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) const entriesTimestamp = useAppSelector(state => state.entries.timestamp)
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId) const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
const hasMore = useAppSelector(state => state.entries.hasMore) const hasMore = useAppSelector(state => state.entries.hasMore)
const loading = useAppSelector(state => state.entries.loading) const loading = useAppSelector(state => state.entries.loading)
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks) const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry) const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible) const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu) const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
const { viewMode } = useViewMode() const { viewMode } = useViewMode()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { openLinkInBackgroundTab } = useBrowserExtension() const { openLinkInBackgroundTab } = useBrowserExtension()
const selectedEntry = entries.find(e => e.id === selectedEntryId) const selectedEntry = entries.find(e => e.id === selectedEntryId)
const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => { const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
const middleClick = event.button === 1 || event.ctrlKey || event.metaKey const middleClick = event.button === 1 || event.ctrlKey || event.metaKey
if (middleClick || viewMode === "expanded") { if (middleClick || viewMode === "expanded") {
dispatch(markEntry({ entry, read: true })) dispatch(markEntry({ entry, read: true }))
} else if (event.button === 0) { } else if (event.button === 0) {
// main click // main click
// don't trigger the link // don't trigger the link
event.preventDefault() event.preventDefault()
dispatch( dispatch(
selectEntry({ selectEntry({
entry, entry,
expand: !entry.expanded, expand: !entry.expanded,
markAsRead: !entry.expanded, markAsRead: !entry.expanded,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
} }
const contextMenu = useContextMenu() const contextMenu = useContextMenu()
const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => { const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
if (event.shiftKey || !customContextMenu) return if (event.shiftKey || !customContextMenu) return
event.preventDefault() event.preventDefault()
contextMenu.show({ contextMenu.show({
id: Constants.dom.entryContextMenuId(entry), id: Constants.dom.entryContextMenuId(entry),
event, event,
}) })
} }
const bodyClicked = (entry: ExpendableEntry) => { const bodyClicked = (entry: ExpendableEntry) => {
if (viewMode !== "expanded") return if (viewMode !== "expanded") return
// entry is already selected // entry is already selected
if (entry.id === selectedEntryId) return if (entry.id === selectedEntryId) return
dispatch( dispatch(
selectEntry({ selectEntry({
entry, entry,
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read })) const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
// close context menu on scroll // close context menu on scroll
useEffect(() => { useEffect(() => {
const listener = throttle(100, () => contextMenu.hideAll()) const listener = throttle(100, () => contextMenu.hideAll())
window.addEventListener("scroll", listener) window.addEventListener("scroll", listener)
return () => window.removeEventListener("scroll", listener) return () => window.removeEventListener("scroll", listener)
}, [contextMenu]) }, [contextMenu])
useEffect(() => { useEffect(() => {
const listener = throttle(100, () => { const listener = throttle(100, () => {
if (viewMode !== "expanded") return if (viewMode !== "expanded") return
if (scrollingToEntry) return if (scrollingToEntry) return
const currentEntry = entries const currentEntry = entries
// use slice to get a copy of the array because reverse mutates the array in-place // use slice to get a copy of the array because reverse mutates the array in-place
.slice() .slice()
.reverse() .reverse()
.find(e => { .find(e => {
const el = document.getElementById(Constants.dom.entryId(e)) const el = document.getElementById(Constants.dom.entryId(e))
return el && !Constants.layout.isTopVisible(el) return el && !Constants.layout.isTopVisible(el)
}) })
if (currentEntry) { if (currentEntry) {
dispatch( dispatch(
selectEntry({ selectEntry({
entry: currentEntry, entry: currentEntry,
expand: false, expand: false,
markAsRead: !!scrollMarks, markAsRead: !!scrollMarks,
scrollToEntry: false, scrollToEntry: false,
}) })
) )
} }
}) })
window.addEventListener("scroll", listener) window.addEventListener("scroll", listener)
return () => window.removeEventListener("scroll", listener) return () => window.removeEventListener("scroll", listener)
}, [dispatch, contextMenu, entries, viewMode, scrollMarks, scrollingToEntry]) }, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
useMousetrap("r", async () => await dispatch(reloadEntries())) useMousetrap("r", async () => await dispatch(reloadEntries()))
useMousetrap( useMousetrap(
"j", "j",
async () => async () =>
await dispatch( await dispatch(
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
) )
useMousetrap( useMousetrap(
"n", "n",
async () => async () =>
await dispatch( await dispatch(
selectNextEntry({ selectNextEntry({
expand: false, expand: false,
markAsRead: false, markAsRead: false,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
) )
useMousetrap( useMousetrap(
"k", "k",
async () => async () =>
await dispatch( await dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
) )
useMousetrap( useMousetrap(
"p", "p",
async () => async () =>
await dispatch( await dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: false, expand: false,
markAsRead: false, markAsRead: false,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
) )
useMousetrap("space", () => { useMousetrap("space", () => {
if (selectedEntry) { if (selectedEntry) {
if (selectedEntry.expanded) { if (selectedEntry.expanded) {
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry)) const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
if (entryElement && Constants.layout.isBottomVisible(entryElement)) { if (entryElement && Constants.layout.isBottomVisible(entryElement)) {
dispatch( dispatch(
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} else { } else {
window.scrollTo({ window.scrollTo({
top: window.scrollY + document.documentElement.clientHeight * 0.8, top: window.scrollY + document.documentElement.clientHeight * 0.8,
behavior: "smooth", behavior: "smooth",
}) })
} }
} else { } else {
dispatch( dispatch(
selectEntry({ selectEntry({
entry: selectedEntry, entry: selectedEntry,
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
} else { } else {
dispatch( dispatch(
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
}) })
useMousetrap("shift+space", () => { useMousetrap("shift+space", () => {
if (selectedEntry) { if (selectedEntry) {
if (selectedEntry.expanded) { if (selectedEntry.expanded) {
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry)) const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
if (entryElement && Constants.layout.isTopVisible(entryElement)) { if (entryElement && Constants.layout.isTopVisible(entryElement)) {
dispatch( dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} else { } else {
window.scrollTo({ window.scrollTo({
top: window.scrollY - document.documentElement.clientHeight * 0.8, top: window.scrollY - document.documentElement.clientHeight * 0.8,
behavior: "smooth", behavior: "smooth",
}) })
} }
} else { } else {
dispatch( dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
} }
}) })
useMousetrap(["o", "enter"], () => { useMousetrap(["o", "enter"], () => {
// toggle expanded status // toggle expanded status
if (!selectedEntry) return if (!selectedEntry) return
dispatch( dispatch(
selectEntry({ selectEntry({
entry: selectedEntry, entry: selectedEntry,
expand: !selectedEntry.expanded, expand: !selectedEntry.expanded,
markAsRead: !selectedEntry.expanded, markAsRead: !selectedEntry.expanded,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
}) })
useMousetrap("v", () => { useMousetrap("v", () => {
// open tab in foreground // open tab in foreground
if (!selectedEntry) return if (!selectedEntry) return
window.open(selectedEntry.url, "_blank", "noreferrer") window.open(selectedEntry.url, "_blank", "noreferrer")
}) })
useMousetrap("b", () => { useMousetrap("b", () => {
if (!selectedEntry) return if (!selectedEntry) return
openLinkInBackgroundTab(selectedEntry.url) openLinkInBackgroundTab(selectedEntry.url)
}) })
useMousetrap("m", () => { useMousetrap("m", () => {
// toggle read status // toggle read status
if (!selectedEntry) return if (!selectedEntry) return
dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read })) dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read }))
}) })
useMousetrap("s", () => { useMousetrap("s", () => {
// toggle starred status // toggle starred status
if (!selectedEntry) return if (!selectedEntry) return
dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred })) dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred }))
}) })
useMousetrap("shift+a", () => { useMousetrap("shift+a", () => {
// mark all entries as read // mark all entries as read
dispatch( dispatch(
markAllEntries({ markAllEntries({
sourceType: source.type, sourceType: source.type,
req: { req: {
id: source.id, id: source.id,
read: true, read: true,
olderThan: Date.now(), olderThan: Date.now(),
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
}, },
}) })
) )
}) })
useMousetrap("g a", async () => await dispatch(redirectToRootCategory())) useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
useMousetrap("f", () => dispatch(toggleSidebar())) useMousetrap("f", () => dispatch(toggleSidebar()))
useMousetrap("?", () => useMousetrap("?", () =>
openModal({ openModal({
title: <Trans>Keyboard shortcuts</Trans>, title: <Trans>Keyboard shortcuts</Trans>,
size: "xl", size: "xl",
children: <KeyboardShortcutsHelp />, children: <KeyboardShortcutsHelp />,
}) })
) )
return ( return (
<InfiniteScroll <InfiniteScroll
id="entries" id="entries"
className={`view-mode-${viewMode}`} className={`view-mode-${viewMode}`}
initialLoad={false} initialLoad={false}
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))} loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
hasMore={hasMore} hasMore={hasMore}
loader={<Box key={0}>{loading && <Loader />}</Box>} loader={<Box key={0}>{loading && <Loader />}</Box>}
> >
{entries.map(entry => ( {entries.map(entry => (
<div <div
key={entry.id} key={entry.id}
ref={el => { ref={el => {
if (el) el.id = Constants.dom.entryId(entry) if (el) el.id = Constants.dom.entryId(entry)
}} }}
> >
<FeedEntry <FeedEntry
entry={entry} entry={entry}
expanded={!!entry.expanded || viewMode === "expanded"} expanded={!!entry.expanded || viewMode === "expanded"}
selected={entry.id === selectedEntryId} selected={entry.id === selectedEntryId}
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")} showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined} maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
onHeaderClick={event => headerClicked(entry, event)} onHeaderClick={event => headerClicked(entry, event)}
onHeaderRightClick={event => headerRightClicked(entry, event)} onHeaderRightClick={event => headerRightClicked(entry, event)}
onBodyClick={() => bodyClicked(entry)} onBodyClick={() => bodyClicked(entry)}
onSwipedLeft={async () => await swipedLeft(entry)} onSwipedLeft={async () => await swipedLeft(entry)}
/> />
</div> </div>
))} ))}
</InfiniteScroll> </InfiniteScroll>
) )
} }

View File

@@ -1,191 +1,191 @@
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core" import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type Entry, type ViewMode } from "app/types" import type { Entry, ViewMode } from "app/types"
import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader" import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader"
import { FeedEntryHeader } from "components/content/header/FeedEntryHeader" import { FeedEntryHeader } from "components/content/header/FeedEntryHeader"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { useViewMode } from "hooks/useViewMode" import { useViewMode } from "hooks/useViewMode"
import React from "react" import type React from "react"
import { useSwipeable } from "react-swipeable" import { useSwipeable } from "react-swipeable"
import { tss } from "tss" import { tss } from "tss"
import { FeedEntryBody } from "./FeedEntryBody" import { FeedEntryBody } from "./FeedEntryBody"
import { FeedEntryContextMenu } from "./FeedEntryContextMenu" import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
import { FeedEntryFooter } from "./FeedEntryFooter" import { FeedEntryFooter } from "./FeedEntryFooter"
interface FeedEntryProps { interface FeedEntryProps {
entry: Entry entry: Entry
expanded: boolean expanded: boolean
selected: boolean selected: boolean
showSelectionIndicator: boolean showSelectionIndicator: boolean
maxWidth?: number maxWidth?: number
onHeaderClick: (e: React.MouseEvent) => void onHeaderClick: (e: React.MouseEvent) => void
onHeaderRightClick: (e: React.MouseEvent) => void onHeaderRightClick: (e: React.MouseEvent) => void
onBodyClick: (e: React.MouseEvent) => void onBodyClick: (e: React.MouseEvent) => void
onSwipedLeft: () => void onSwipedLeft: () => void
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
read: boolean read: boolean
expanded: boolean expanded: boolean
viewMode: ViewMode viewMode: ViewMode
rtl: boolean rtl: boolean
showSelectionIndicator: boolean showSelectionIndicator: boolean
maxWidth?: number maxWidth?: number
}>() }>()
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => { .create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
let backgroundColor let backgroundColor: string
if (colorScheme === "dark") { if (colorScheme === "dark") {
backgroundColor = read ? "inherit" : theme.colors.dark[5] backgroundColor = read ? "inherit" : theme.colors.dark[5]
} else { } else {
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit" backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
} }
let marginY = 10 let marginY = 10
if (viewMode === "title") { if (viewMode === "title") {
marginY = 2 marginY = 2
} else if (viewMode === "cozy") { } else if (viewMode === "cozy") {
marginY = 6 marginY = 6
} }
let mobileMarginY = 6 let mobileMarginY = 6
if (viewMode === "title") { if (viewMode === "title") {
mobileMarginY = 2 mobileMarginY = 2
} else if (viewMode === "cozy") { } else if (viewMode === "cozy") {
mobileMarginY = 4 mobileMarginY = 4
} }
let backgroundHoverColor = backgroundColor let backgroundHoverColor = backgroundColor
if (!expanded && !read) { if (!expanded && !read) {
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1] backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
} }
let paperBorderLeftColor let paperBorderLeftColor = ""
if (showSelectionIndicator) { if (showSelectionIndicator) {
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6] const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
paperBorderLeftColor = `${borderLeftColor} !important` paperBorderLeftColor = `${borderLeftColor} !important`
} }
return { return {
paper: { paper: {
backgroundColor, backgroundColor,
borderLeftColor: paperBorderLeftColor, borderLeftColor: paperBorderLeftColor,
marginTop: marginY, marginTop: marginY,
marginBottom: marginY, marginBottom: marginY,
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: { [`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
marginTop: mobileMarginY, marginTop: mobileMarginY,
marginBottom: mobileMarginY, marginBottom: mobileMarginY,
}, },
"@media (hover: hover)": { "@media (hover: hover)": {
"&:hover": { "&:hover": {
backgroundColor: backgroundHoverColor, backgroundColor: backgroundHoverColor,
}, },
}, },
}, },
headerLink: { headerLink: {
color: "inherit", color: "inherit",
textDecoration: "none", textDecoration: "none",
}, },
body: { body: {
direction: rtl ? "rtl" : "ltr", direction: rtl ? "rtl" : "ltr",
maxWidth: maxWidth ?? "100%", maxWidth: maxWidth ?? "100%",
}, },
} }
}) })
export function FeedEntry(props: FeedEntryProps) { export function FeedEntry(props: FeedEntryProps) {
const { viewMode } = useViewMode() const { viewMode } = useViewMode()
const { classes, cx } = useStyles({ const { classes, cx } = useStyles({
read: props.entry.read, read: props.entry.read,
expanded: props.expanded, expanded: props.expanded,
viewMode, viewMode,
rtl: props.entry.rtl, rtl: props.entry.rtl,
showSelectionIndicator: props.showSelectionIndicator, showSelectionIndicator: props.showSelectionIndicator,
maxWidth: props.maxWidth, maxWidth: props.maxWidth,
}) })
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode) const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode) const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
const mobile = useMobile() const mobile = useMobile()
const showExternalLinkIcon = const showExternalLinkIcon =
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode) externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
const showStarIcon = const showStarIcon =
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode) props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
const swipeHandlers = useSwipeable({ const swipeHandlers = useSwipeable({
onSwipedLeft: props.onSwipedLeft, onSwipedLeft: props.onSwipedLeft,
}) })
let paddingX: MantineSpacing = "xs" let paddingX: MantineSpacing = "xs"
if (viewMode === "title" || viewMode === "cozy") paddingX = 6 if (viewMode === "title" || viewMode === "cozy") paddingX = 6
let paddingY: MantineSpacing = "xs" let paddingY: MantineSpacing = "xs"
if (viewMode === "title") { if (viewMode === "title") {
paddingY = 4 paddingY = 4
} else if (viewMode === "cozy") { } else if (viewMode === "cozy") {
paddingY = 8 paddingY = 8
} }
let borderRadius: MantineRadius = "sm" let borderRadius: MantineRadius = "sm"
if (viewMode === "title") { if (viewMode === "title") {
borderRadius = 0 borderRadius = 0
} else if (viewMode === "cozy") { } else if (viewMode === "cozy") {
borderRadius = "xs" borderRadius = "xs"
} }
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy") const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
return ( return (
<Paper <Paper
withBorder withBorder
radius={borderRadius} radius={borderRadius}
className={cx(classes.paper, { className={cx(classes.paper, {
read: props.entry.read, read: props.entry.read,
unread: !props.entry.read, unread: !props.entry.read,
expanded: props.expanded, expanded: props.expanded,
selected: props.selected, selected: props.selected,
"show-selection-indicator": props.showSelectionIndicator, "show-selection-indicator": props.showSelectionIndicator,
})} })}
> >
<a <a
className={classes.headerLink} className={classes.headerLink}
href={props.entry.url} href={props.entry.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={props.onHeaderClick} onClick={props.onHeaderClick}
onAuxClick={props.onHeaderClick} onAuxClick={props.onHeaderClick}
onContextMenu={props.onHeaderRightClick} onContextMenu={props.onHeaderRightClick}
> >
<Box px={paddingX} py={paddingY} {...swipeHandlers}> <Box px={paddingX} py={paddingY} {...swipeHandlers}>
{compactHeader && ( {compactHeader && (
<FeedEntryCompactHeader <FeedEntryCompactHeader
entry={props.entry} entry={props.entry}
showStarIcon={showStarIcon} showStarIcon={showStarIcon}
showExternalLinkIcon={showExternalLinkIcon} showExternalLinkIcon={showExternalLinkIcon}
/> />
)} )}
{!compactHeader && ( {!compactHeader && (
<FeedEntryHeader <FeedEntryHeader
entry={props.entry} entry={props.entry}
expanded={props.expanded} expanded={props.expanded}
showStarIcon={showStarIcon} showStarIcon={showStarIcon}
showExternalLinkIcon={showExternalLinkIcon} showExternalLinkIcon={showExternalLinkIcon}
/> />
)} )}
</Box> </Box>
</a> </a>
{props.expanded && ( {props.expanded && (
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}> <Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
<Box className={classes.body}> <Box className={classes.body}>
<FeedEntryBody entry={props.entry} /> <FeedEntryBody entry={props.entry} />
</Box> </Box>
<Divider variant="dashed" my={paddingY} /> <Divider variant="dashed" my={paddingY} />
<FeedEntryFooter entry={props.entry} /> <FeedEntryFooter entry={props.entry} />
</Box> </Box>
)} )}
<FeedEntryContextMenu entry={props.entry} /> <FeedEntryContextMenu entry={props.entry} />
</Paper> </Paper>
) )
} }

View File

@@ -1,37 +1,37 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { Content } from "./Content" import { Content } from "./Content"
import { Enclosure } from "./Enclosure" import { Enclosure } from "./Enclosure"
import { Media } from "./Media" import { Media } from "./Media"
export interface FeedEntryBodyProps { export interface FeedEntryBodyProps {
entry: Entry entry: Entry
} }
export function FeedEntryBody(props: FeedEntryBodyProps) { export function FeedEntryBody(props: FeedEntryBodyProps) {
const search = useAppSelector(state => state.entries.search) const search = useAppSelector(state => state.entries.search)
return ( return (
<Box> <Box>
<Box> <Box>
<Content content={props.entry.content} highlight={search} /> <Content content={props.entry.content} highlight={search} />
</Box> </Box>
{props.entry.enclosureType && props.entry.enclosureUrl && ( {props.entry.enclosureType && props.entry.enclosureUrl && (
<Box pt="md"> <Box pt="md">
<Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} /> <Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} />
</Box> </Box>
)} )}
{/* show media only if we don't have content to avoid duplicate content */} {/* show media only if we don't have content to avoid duplicate content */}
{!props.entry.content && props.entry.mediaThumbnailUrl && ( {!props.entry.content && props.entry.mediaThumbnailUrl && (
<Box pt="md"> <Box pt="md">
<Media <Media
thumbnailUrl={props.entry.mediaThumbnailUrl} thumbnailUrl={props.entry.mediaThumbnailUrl}
thumbnailWidth={props.entry.mediaThumbnailWidth} thumbnailWidth={props.entry.mediaThumbnailWidth}
thumbnailHeight={props.entry.mediaThumbnailHeight} thumbnailHeight={props.entry.mediaThumbnailHeight}
description={props.entry.mediaDescription} description={props.entry.mediaDescription}
/> />
</Box> </Box>
)} )}
</Box> </Box>
) )
} }

View File

@@ -1,103 +1,103 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Group } from "@mantine/core" import { Group } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks" import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
import { redirectToFeed } from "app/redirect/thunks" import { redirectToFeed } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { truncate } from "app/utils" import { truncate } from "app/utils"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useColorScheme } from "hooks/useColorScheme" import { useColorScheme } from "hooks/useColorScheme"
import { Item, Menu, Separator } from "react-contexify" import { Item, Menu, Separator } from "react-contexify"
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbRss, TbStar, TbStarOff } from "react-icons/tb" import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbRss, TbStar, TbStarOff } from "react-icons/tb"
import { tss } from "tss" import { tss } from "tss"
interface FeedEntryContextMenuProps { interface FeedEntryContextMenuProps {
entry: Entry entry: Entry
} }
const iconSize = 16 const iconSize = 16
const useStyles = tss.create(({ theme, colorScheme }) => ({ const useStyles = tss.create(({ theme, colorScheme }) => ({
menu: { menu: {
// apply mantine theme from MenuItem.styles.ts // apply mantine theme from MenuItem.styles.ts
fontSize: theme.fontSizes.sm, fontSize: theme.fontSizes.sm,
"--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`, "--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
"--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`, "--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
"--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`, "--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`,
}, },
})) }))
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) { export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
const { classes } = useStyles() const { classes } = useStyles()
const sourceType = useAppSelector(state => state.entries.source.type) const sourceType = useAppSelector(state => state.entries.source.type)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { openLinkInBackgroundTab } = useBrowserExtension() const { openLinkInBackgroundTab } = useBrowserExtension()
return ( return (
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}> <Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
<Item <Item
onClick={() => { onClick={() => {
window.open(props.entry.url, "_blank", "noreferrer") window.open(props.entry.url, "_blank", "noreferrer")
dispatch(markEntry({ entry: props.entry, read: true })) dispatch(markEntry({ entry: props.entry, read: true }))
}} }}
> >
<Group> <Group>
<TbExternalLink size={iconSize} /> <TbExternalLink size={iconSize} />
<Trans>Open link in new tab</Trans> <Trans>Open link in new tab</Trans>
</Group> </Group>
</Item> </Item>
<Item <Item
onClick={() => { onClick={() => {
openLinkInBackgroundTab(props.entry.url) openLinkInBackgroundTab(props.entry.url)
dispatch(markEntry({ entry: props.entry, read: true })) dispatch(markEntry({ entry: props.entry, read: true }))
}} }}
> >
<Group> <Group>
<TbExternalLink size={iconSize} /> <TbExternalLink size={iconSize} />
<Trans>Open link in new background tab</Trans> <Trans>Open link in new background tab</Trans>
</Group> </Group>
</Item> </Item>
<Separator /> <Separator />
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}> <Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
<Group> <Group>
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />} {props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} {props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
</Group> </Group>
</Item> </Item>
{props.entry.markable && ( {props.entry.markable && (
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}> <Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
<Group> <Group>
{props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck size={iconSize} />} {props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck size={iconSize} />}
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>} {props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
</Group> </Group>
</Item> </Item>
)} )}
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}> <Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
<Group> <Group>
<TbArrowBarToDown size={iconSize} /> <TbArrowBarToDown size={iconSize} />
<Trans>Mark as read up to here</Trans> <Trans>Mark as read up to here</Trans>
</Group> </Group>
</Item> </Item>
{sourceType === "category" && ( {sourceType === "category" && (
<> <>
<Separator /> <Separator />
<Item <Item
onClick={() => { onClick={() => {
dispatch(redirectToFeed(props.entry.feedId)) dispatch(redirectToFeed(props.entry.feedId))
}} }}
> >
<Group> <Group>
<TbRss size={iconSize} /> <TbRss size={iconSize} />
<Trans>Go to {truncate(props.entry.feedName, 30)}</Trans> <Trans>Go to {truncate(props.entry.feedName, 30)}</Trans>
</Group> </Group>
</Item> </Item>
</> </>
)} )}
</Menu> </Menu>
) )
} }

View File

@@ -1,102 +1,102 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Group, Indicator, Popover, TagsInput } from "@mantine/core" import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks" import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { ActionButton } from "components/ActionButton" import { ActionButton } from "components/ActionButton"
import { useActionButton } from "hooks/useActionButton" import { useActionButton } from "hooks/useActionButton"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb" import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
import { ShareButtons } from "./ShareButtons" import { ShareButtons } from "./ShareButtons"
interface FeedEntryFooterProps { interface FeedEntryFooterProps {
entry: Entry entry: Entry
} }
export function FeedEntryFooter(props: FeedEntryFooterProps) { export function FeedEntryFooter(props: FeedEntryFooterProps) {
const tags = useAppSelector(state => state.user.tags) const tags = useAppSelector(state => state.user.tags)
const mobile = useMobile() const mobile = useMobile()
const { spacing } = useActionButton() const { spacing } = useActionButton()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const readStatusButtonClicked = async () => const readStatusButtonClicked = async () =>
await dispatch( await dispatch(
markEntry({ markEntry({
entry: props.entry, entry: props.entry,
read: !props.entry.read, read: !props.entry.read,
}) })
) )
const onTagsChange = async (values: string[]) => const onTagsChange = async (values: string[]) =>
await dispatch( await dispatch(
tagEntry({ tagEntry({
entryId: +props.entry.id, entryId: +props.entry.id,
tags: values, tags: values,
}) })
) )
return ( return (
<Group justify="space-between"> <Group justify="space-between">
<Group gap={spacing}> <Group gap={spacing}>
{props.entry.markable && ( {props.entry.markable && (
<ActionButton <ActionButton
icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />} icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />}
label={props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>} label={props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
onClick={readStatusButtonClicked} onClick={readStatusButtonClicked}
/> />
)} )}
<ActionButton <ActionButton
icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />} icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />}
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
onClick={async () => onClick={async () =>
await dispatch( await dispatch(
starEntry({ starEntry({
entry: props.entry, entry: props.entry,
starred: !props.entry.starred, starred: !props.entry.starred,
}) })
) )
} }
/> />
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}> <Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
<Popover.Target> <Popover.Target>
<ActionButton icon={<TbShare size={18} />} label={<Trans>Share</Trans>} /> <ActionButton icon={<TbShare size={18} />} label={<Trans>Share</Trans>} />
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<ShareButtons url={props.entry.url} description={props.entry.title} /> <ShareButtons url={props.entry.url} description={props.entry.title} />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
{tags && ( {tags && (
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}> <Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
<Popover.Target> <Popover.Target>
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}> <Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
<ActionButton icon={<TbTag size={18} />} label={<Trans>Tags</Trans>} /> <ActionButton icon={<TbTag size={18} />} label={<Trans>Tags</Trans>} />
</Indicator> </Indicator>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<TagsInput <TagsInput
placeholder={t`Tags`} placeholder={t`Tags`}
data={tags} data={tags}
value={props.entry.tags} value={props.entry.tags}
onChange={onTagsChange} onChange={onTagsChange}
comboboxProps={{ comboboxProps={{
withinPortal: false, withinPortal: false,
}} }}
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
)} )}
<a href={props.entry.url} target="_blank" rel="noreferrer"> <a href={props.entry.url} target="_blank" rel="noreferrer">
<ActionButton icon={<TbExternalLink size={18} />} label={<Trans>Open link</Trans>} /> <ActionButton icon={<TbExternalLink size={18} />} label={<Trans>Open link</Trans>} />
</a> </a>
</Group> </Group>
<ActionButton <ActionButton
icon={<TbArrowBarToDown size={18} />} icon={<TbArrowBarToDown size={18} />}
label={<Trans>Mark as read up to here</Trans>} label={<Trans>Mark as read up to here</Trans>}
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))} onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
/> />
</Group> </Group>
) )
} }

View File

@@ -1,21 +1,21 @@
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
export interface FeedFaviconProps { export interface FeedFaviconProps {
url: string url: string
size?: number size?: number
} }
export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) { export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
return ( return (
<ImageWithPlaceholderWhileLoading <ImageWithPlaceholderWhileLoading
src={url} src={url}
alt="feed favicon" alt="feed favicon"
width={size} width={size}
height={size} height={size}
placeholderWidth={size} placeholderWidth={size}
placeholderHeight={size} placeholderHeight={size}
placeholderBackgroundColor="inherit" placeholderBackgroundColor="inherit"
placeholderIconSize={size} placeholderIconSize={size}
/> />
) )
} }

View File

@@ -1,40 +1,40 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { calculatePlaceholderSize } from "app/utils" import { calculatePlaceholderSize } from "app/utils"
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
import { Content } from "./Content" import { Content } from "./Content"
export interface MediaProps { export interface MediaProps {
thumbnailUrl: string thumbnailUrl: string
thumbnailWidth?: number thumbnailWidth?: number
thumbnailHeight?: number thumbnailHeight?: number
description?: string description?: string
} }
export function Media(props: MediaProps) { export function Media(props: MediaProps) {
const width = props.thumbnailWidth const width = props.thumbnailWidth
const height = props.thumbnailHeight const height = props.thumbnailHeight
const placeholderSize = calculatePlaceholderSize({ const placeholderSize = calculatePlaceholderSize({
width, width,
height, height,
maxWidth: Constants.layout.entryMaxWidth, maxWidth: Constants.layout.entryMaxWidth,
}) })
return ( return (
<BasicHtmlStyles> <BasicHtmlStyles>
<ImageWithPlaceholderWhileLoading <ImageWithPlaceholderWhileLoading
src={props.thumbnailUrl} src={props.thumbnailUrl}
alt="media thumbnail" alt="media thumbnail"
width={props.thumbnailWidth} width={props.thumbnailWidth}
height={props.thumbnailHeight} height={props.thumbnailHeight}
placeholderWidth={placeholderSize.width} placeholderWidth={placeholderSize.width}
placeholderHeight={placeholderSize.height} placeholderHeight={placeholderSize.height}
/> />
{props.description && ( {props.description && (
<Box pt="md"> <Box pt="md">
<Content content={props.description} /> <Content content={props.description} />
</Box> </Box>
)} )}
</BasicHtmlStyles> </BasicHtmlStyles>
) )
} }

View File

@@ -1,113 +1,113 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core" import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type SharingSettings } from "app/types" import type { SharingSettings } from "app/types"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { type IconType } from "react-icons" import type { IconType } from "react-icons"
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb" import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
import { tss } from "tss" import { tss } from "tss"
type Color = `#${string}` type Color = `#${string}`
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
color: Color color: Color
}>() }>()
.create(({ theme, colorScheme, color }) => ({ .create(({ theme, colorScheme, color }) => ({
icon: { icon: {
color, color,
backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white", backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white",
}, },
})) }))
function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; onClick: () => void }) { function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; onClick: () => void }) {
const { classes } = useStyles({ const { classes } = useStyles({
color, color,
}) })
return ( return (
<ActionIcon variant="transparent" radius="xl" size={32}> <ActionIcon variant="transparent" radius="xl" size={32}>
<Box p={6} className={classes.icon} onClick={onClick}> <Box p={6} className={classes.icon} onClick={onClick}>
{icon({ size: 18 })} {icon({ size: 18 })}
</Box> </Box>
</ActionIcon> </ActionIcon>
) )
} }
function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; url: string }) { function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; url: string }) {
const onClick = () => { const onClick = () => {
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600") window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
} }
return <ShareButton icon={icon} color={color} onClick={onClick} /> return <ShareButton icon={icon} color={color} onClick={onClick} />
} }
function CopyUrlButton({ url }: { url: string }) { function CopyUrlButton({ url }: { url: string }) {
return ( return (
<CopyButton value={url}> <CopyButton value={url}>
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />} {({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
</CopyButton> </CopyButton>
) )
} }
function BrowserNativeShareButton({ url, description }: { url: string; description: string }) { function BrowserNativeShareButton({ url, description }: { url: string; description: string }) {
const mobile = useMobile() const mobile = useMobile()
const { isBrowserExtensionPopup } = useBrowserExtension() const { isBrowserExtensionPopup } = useBrowserExtension()
const onClick = () => { const onClick = () => {
navigator.share({ navigator.share({
title: description, title: description,
url, url,
}) })
} }
return ( return (
<ShareButton <ShareButton
icon={mobile && !isBrowserExtensionPopup ? TbDeviceMobileShare : TbDeviceDesktopShare} icon={mobile && !isBrowserExtensionPopup ? TbDeviceMobileShare : TbDeviceDesktopShare}
color="#000" color="#000"
onClick={onClick} onClick={onClick}
/> />
) )
} }
export function ShareButtons(props: { url: string; description: string }) { export function ShareButtons(props: { url: string; description: string }) {
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site]) const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
const url = encodeURIComponent(props.url) const url = encodeURIComponent(props.url)
const desc = encodeURIComponent(props.description) const desc = encodeURIComponent(props.description)
const clipboardAvailable = typeof navigator.clipboard !== "undefined" const clipboardAvailable = typeof navigator.clipboard !== "undefined"
const nativeSharingAvailable = typeof navigator.share !== "undefined" const nativeSharingAvailable = typeof navigator.share !== "undefined"
const showNativeSection = clipboardAvailable || nativeSharingAvailable const showNativeSection = clipboardAvailable || nativeSharingAvailable
const showSharingSites = enabledSharingSites.length > 0 const showSharingSites = enabledSharingSites.length > 0
const showDivider = showNativeSection && showSharingSites const showDivider = showNativeSection && showSharingSites
const showNoSharingOptionsAvailable = !showNativeSection && !showSharingSites const showNoSharingOptionsAvailable = !showNativeSection && !showSharingSites
return ( return (
<> <>
{showNativeSection && ( {showNativeSection && (
<SimpleGrid cols={4}> <SimpleGrid cols={4}>
{clipboardAvailable && <CopyUrlButton url={props.url} />} {clipboardAvailable && <CopyUrlButton url={props.url} />}
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />} {nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
</SimpleGrid> </SimpleGrid>
)} )}
{showDivider && <Divider my="xs" />} {showDivider && <Divider my="xs" />}
{showSharingSites && ( {showSharingSites && (
<SimpleGrid cols={4}> <SimpleGrid cols={4}>
{enabledSharingSites.map(site => ( {enabledSharingSites.map(site => (
<SiteShareButton <SiteShareButton
key={site} key={site}
icon={Constants.sharing[site].icon} icon={Constants.sharing[site].icon}
color={Constants.sharing[site].color} color={Constants.sharing[site].color}
url={Constants.sharing[site].url(url, desc)} url={Constants.sharing[site].url(url, desc)}
/> />
))} ))}
</SimpleGrid> </SimpleGrid>
)} )}
{showNoSharingOptionsAvailable && <Trans>No sharing options available.</Trans>} {showNoSharingOptionsAvailable && <Trans>No sharing options available.</Trans>}
</> </>
) )
} }

View File

@@ -1,50 +1,50 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Box, Button, Group, Stack, TextInput } from "@mantine/core" import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { redirectToSelectedSource } from "app/redirect/thunks" import { redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { type AddCategoryRequest } from "app/types" import type { AddCategoryRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbFolderPlus } from "react-icons/tb" import { TbFolderPlus } from "react-icons/tb"
import { CategorySelect } from "./CategorySelect" import { CategorySelect } from "./CategorySelect"
export function AddCategory() { export function AddCategory() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const form = useForm<AddCategoryRequest>() const form = useForm<AddCategoryRequest>()
const addCategory = useAsyncCallback(client.category.add, { const addCategory = useAsyncCallback(client.category.add, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
}, },
}) })
return ( return (
<> <>
{addCategory.error && ( {addCategory.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(addCategory.error)} /> <Alert messages={errorToStrings(addCategory.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(addCategory.execute)}> <form onSubmit={form.onSubmit(addCategory.execute)}>
<Stack> <Stack>
<TextInput label={<Trans>Category</Trans>} placeholder={t`Category`} {...form.getInputProps("name")} required /> <TextInput label={<Trans>Category</Trans>} placeholder={t`Category`} {...form.getInputProps("name")} required />
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable /> <CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
<Group justify="center"> <Group justify="center">
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}> <Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}>
<Trans>Add</Trans> <Trans>Add</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</> </>
) )
} }

View File

@@ -1,51 +1,52 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { Select, type SelectProps } from "@mantine/core" import { Select, type SelectProps } from "@mantine/core"
import { type ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types" import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type Category } from "app/types" import type { Category } from "app/types"
import { flattenCategoryTree } from "app/utils" import { flattenCategoryTree } from "app/utils"
type CategorySelectProps = Partial<SelectProps> & { type CategorySelectProps = Partial<SelectProps> & {
withAll?: boolean withAll?: boolean
withoutCategoryIds?: string[] withoutCategoryIds?: string[]
} }
export function CategorySelect(props: CategorySelectProps) { export function CategorySelect(props: CategorySelectProps) {
const rootCategory = useAppSelector(state => state.tree.rootCategory) const rootCategory = useAppSelector(state => state.tree.rootCategory)
const categories = rootCategory && flattenCategoryTree(rootCategory) const categories = rootCategory && flattenCategoryTree(rootCategory)
const categoriesById = categories?.reduce((map, c) => { const categoriesById = categories?.reduce((map, c) => {
map.set(c.id, c) map.set(c.id, c)
return map return map
}, new Map<string, Category>()) }, new Map<string, Category>())
const categoryLabel = (cat: Category) => { const categoryLabel = (category: Category) => {
let label = cat.name let cat = category
let label = cat.name
while (cat.parentId) {
const parent = categoriesById?.get(cat.parentId) while (cat.parentId) {
if (!parent) { const parent = categoriesById?.get(cat.parentId)
break if (!parent) {
} break
label = `${parent.name}${label}` }
cat = parent label = `${parent.name}${label}`
} cat = parent
}
return label
} return label
const selectData: ComboboxItem[] | undefined = categories }
?.filter(c => c.id !== Constants.categories.all.id) const selectData: ComboboxItem[] | undefined = categories
.filter(c => !props.withoutCategoryIds?.includes(c.id)) ?.filter(c => c.id !== Constants.categories.all.id)
.map(c => ({ .filter(c => !props.withoutCategoryIds?.includes(c.id))
label: categoryLabel(c), .map(c => ({
value: c.id, label: categoryLabel(c),
})) value: c.id,
.sort((c1, c2) => c1.label.localeCompare(c2.label)) }))
if (props.withAll) { .sort((c1, c2) => c1.label.localeCompare(c2.label))
selectData?.unshift({ if (props.withAll) {
label: t`All`, selectData?.unshift({
value: Constants.categories.all.id, label: t`All`,
}) value: Constants.categories.all.id,
} })
}
return <Select {...props} data={selectData ?? []} disabled={!selectData} />
} return <Select {...props} data={selectData ?? []} disabled={!selectData} />
}

View File

@@ -1,64 +1,64 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Box, Button, FileInput, Group, Stack } from "@mantine/core" import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
import { isNotEmpty, useForm } from "@mantine/form" import { isNotEmpty, useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { redirectToSelectedSource } from "app/redirect/thunks" import { redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbFileImport } from "react-icons/tb" import { TbFileImport } from "react-icons/tb"
export function ImportOpml() { export function ImportOpml() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const form = useForm<{ file: File }>({ const form = useForm<{ file: File }>({
validate: { validate: {
file: isNotEmpty(t`OPML file is required`), file: isNotEmpty(t`OPML file is required`),
}, },
}) })
const importOpml = useAsyncCallback(client.feed.importOpml, { const importOpml = useAsyncCallback(client.feed.importOpml, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
}, },
}) })
return ( return (
<> <>
{importOpml.error && ( {importOpml.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(importOpml.error)} /> <Alert messages={errorToStrings(importOpml.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}> <form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}>
<Stack> <Stack>
<FileInput <FileInput
label={<Trans>OPML file</Trans>} label={<Trans>OPML file</Trans>}
leftSection={<TbFileImport />} leftSection={<TbFileImport />}
placeholder={t`OPML file`} placeholder={t`OPML file`}
description={ description={
<Trans> <Trans>
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
data from other feed reading services. data from other feed reading services.
</Trans> </Trans>
} }
{...form.getInputProps("file")} {...form.getInputProps("file")}
required required
accept=".xml,.opml" accept=".xml,.opml"
/> />
<Group justify="center"> <Group justify="center">
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}> <Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}>
<Trans>Import</Trans> <Trans>Import</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</> </>
) )
} }

View File

@@ -1,129 +1,129 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core" import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks" import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { type FeedInfoRequest, type SubscribeRequest } from "app/types" import type { FeedInfoRequest, SubscribeRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useState } from "react" import { useState } from "react"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbRss } from "react-icons/tb" import { TbRss } from "react-icons/tb"
import { CategorySelect } from "./CategorySelect" import { CategorySelect } from "./CategorySelect"
export function Subscribe() { export function Subscribe() {
const [activeStep, setActiveStep] = useState(0) const [activeStep, setActiveStep] = useState(0)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const step0Form = useForm<FeedInfoRequest>({ const step0Form = useForm<FeedInfoRequest>({
initialValues: { initialValues: {
url: "", url: "",
}, },
}) })
const step1Form = useForm<SubscribeRequest>({ const step1Form = useForm<SubscribeRequest>({
initialValues: { initialValues: {
url: "", url: "",
title: "", title: "",
categoryId: Constants.categories.all.id, categoryId: Constants.categories.all.id,
}, },
}) })
const fetchFeed = useAsyncCallback(client.feed.fetchFeed, { const fetchFeed = useAsyncCallback(client.feed.fetchFeed, {
onSuccess: ({ data }) => { onSuccess: ({ data }) => {
step1Form.setFieldValue("url", data.url) step1Form.setFieldValue("url", data.url)
step1Form.setFieldValue("title", data.title) step1Form.setFieldValue("title", data.title)
setActiveStep(step => step + 1) setActiveStep(step => step + 1)
}, },
}) })
const subscribe = useAsyncCallback(client.feed.subscribe, { const subscribe = useAsyncCallback(client.feed.subscribe, {
onSuccess: sub => { onSuccess: sub => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToFeed(sub.data)) dispatch(redirectToFeed(sub.data))
}, },
}) })
const previousStep = () => { const previousStep = () => {
if (activeStep === 0) { if (activeStep === 0) {
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
} else { } else {
setActiveStep(activeStep - 1) setActiveStep(activeStep - 1)
} }
} }
const nextStep = (e: React.FormEvent<HTMLFormElement>) => { const nextStep = (e: React.FormEvent<HTMLFormElement>) => {
if (activeStep === 0) { if (activeStep === 0) {
step0Form.onSubmit(fetchFeed.execute)(e) step0Form.onSubmit(fetchFeed.execute)(e)
} else if (activeStep === 1) { } else if (activeStep === 1) {
step1Form.onSubmit(subscribe.execute)(e) step1Form.onSubmit(subscribe.execute)(e)
} }
} }
return ( return (
<> <>
{fetchFeed.error && ( {fetchFeed.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(fetchFeed.error)} /> <Alert messages={errorToStrings(fetchFeed.error)} />
</Box> </Box>
)} )}
{subscribe.error && ( {subscribe.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(subscribe.error)} /> <Alert messages={errorToStrings(subscribe.error)} />
</Box> </Box>
)} )}
<form onSubmit={nextStep}> <form onSubmit={nextStep}>
<Stepper active={activeStep} onStepClick={setActiveStep}> <Stepper active={activeStep} onStepClick={setActiveStep}>
<Stepper.Step <Stepper.Step
label={<Trans>Analyze feed</Trans>} label={<Trans>Analyze feed</Trans>}
description={<Trans>Check that the feed is working</Trans>} description={<Trans>Check that the feed is working</Trans>}
allowStepSelect={activeStep === 1} allowStepSelect={activeStep === 1}
> >
<TextInput <TextInput
label={<Trans>Feed URL</Trans>} label={<Trans>Feed URL</Trans>}
placeholder="https://www.mysite.com/rss" placeholder="https://www.mysite.com/rss"
description={ description={
<Trans> <Trans>
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
will try to find the feed in the page. will try to find the feed in the page.
</Trans> </Trans>
} }
required required
autoFocus autoFocus
{...step0Form.getInputProps("url")} {...step0Form.getInputProps("url")}
/> />
</Stepper.Step> </Stepper.Step>
<Stepper.Step <Stepper.Step
label={<Trans>Subscribe</Trans>} label={<Trans>Subscribe</Trans>}
description={<Trans>Subscribe to the feed</Trans>} description={<Trans>Subscribe to the feed</Trans>}
allowStepSelect={false} allowStepSelect={false}
> >
<Stack> <Stack>
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled /> <TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus /> <TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable /> <CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
</Stack> </Stack>
</Stepper.Step> </Stepper.Step>
</Stepper> </Stepper>
<Group justify="center" mt="xl"> <Group justify="center" mt="xl">
<Button variant="default" onClick={previousStep}> <Button variant="default" onClick={previousStep}>
<Trans>Back</Trans> <Trans>Back</Trans>
</Button> </Button>
{activeStep === 0 && ( {activeStep === 0 && (
<Button type="submit" loading={fetchFeed.loading}> <Button type="submit" loading={fetchFeed.loading}>
<Trans>Next</Trans> <Trans>Next</Trans>
</Button> </Button>
)} )}
{activeStep === 1 && ( {activeStep === 1 && (
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}> <Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
<Trans>Subscribe</Trans> <Trans>Subscribe</Trans>
</Button> </Button>
)} )}
</Group> </Group>
</form> </form>
</> </>
) )
} }

View File

@@ -1,72 +1,72 @@
import { Box, Text } from "@mantine/core" import { Box, Text } from "@mantine/core"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { FeedFavicon } from "components/content/FeedFavicon" import { RelativeDate } from "components/RelativeDate"
import { OpenExternalLink } from "components/content/header/OpenExternalLink" import { FeedFavicon } from "components/content/FeedFavicon"
import { Star } from "components/content/header/Star" import { OpenExternalLink } from "components/content/header/OpenExternalLink"
import { RelativeDate } from "components/RelativeDate" import { Star } from "components/content/header/Star"
import { OnDesktop } from "components/responsive/OnDesktop" import { OnDesktop } from "components/responsive/OnDesktop"
import { tss } from "tss" import { tss } from "tss"
import { FeedEntryTitle } from "./FeedEntryTitle" import { FeedEntryTitle } from "./FeedEntryTitle"
export interface FeedEntryHeaderProps { export interface FeedEntryHeaderProps {
entry: Entry entry: Entry
showStarIcon?: boolean showStarIcon?: boolean
showExternalLinkIcon?: boolean showExternalLinkIcon?: boolean
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
read: boolean read: boolean
}>() }>()
.create(({ colorScheme, read }) => ({ .create(({ colorScheme, read }) => ({
wrapper: { wrapper: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
columnGap: "10px", columnGap: "10px",
}, },
title: { title: {
flexGrow: 1, flexGrow: 1,
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit", fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
}, },
feedName: { feedName: {
width: "145px", width: "145px",
minWidth: "145px", minWidth: "145px",
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
}, },
date: { date: {
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
})) }))
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) { export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
const { classes } = useStyles({ const { classes } = useStyles({
read: props.entry.read, read: props.entry.read,
}) })
return ( return (
<Box className={classes.wrapper}> <Box className={classes.wrapper}>
{props.showStarIcon && <Star entry={props.entry} />} {props.showStarIcon && <Star entry={props.entry} />}
<Box> <Box>
<FeedFavicon url={props.entry.iconUrl} /> <FeedFavicon url={props.entry.iconUrl} />
</Box> </Box>
<OnDesktop> <OnDesktop>
<Text c="dimmed" className={classes.feedName}> <Text c="dimmed" className={classes.feedName}>
{props.entry.feedName} {props.entry.feedName}
</Text> </Text>
</OnDesktop> </OnDesktop>
<Box className={classes.title}> <Box className={classes.title}>
<FeedEntryTitle entry={props.entry} /> <FeedEntryTitle entry={props.entry} />
</Box> </Box>
<OnDesktop> <OnDesktop>
<Text c="dimmed" className={classes.date}> <Text c="dimmed" className={classes.date}>
<RelativeDate date={props.entry.date} /> <RelativeDate date={props.entry.date} />
</Text> </Text>
</OnDesktop> </OnDesktop>
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />} {props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
</Box> </Box>
) )
} }

View File

@@ -1,67 +1,67 @@
import { Box, Flex, Space, Text } from "@mantine/core" import { Box, Flex, Space, Text } from "@mantine/core"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { FeedFavicon } from "components/content/FeedFavicon" import { RelativeDate } from "components/RelativeDate"
import { OpenExternalLink } from "components/content/header/OpenExternalLink" import { FeedFavicon } from "components/content/FeedFavicon"
import { Star } from "components/content/header/Star" import { OpenExternalLink } from "components/content/header/OpenExternalLink"
import { RelativeDate } from "components/RelativeDate" import { Star } from "components/content/header/Star"
import { tss } from "tss" import { tss } from "tss"
import { FeedEntryTitle } from "./FeedEntryTitle" import { FeedEntryTitle } from "./FeedEntryTitle"
export interface FeedEntryHeaderProps { export interface FeedEntryHeaderProps {
entry: Entry entry: Entry
expanded: boolean expanded: boolean
showStarIcon?: boolean showStarIcon?: boolean
showExternalLinkIcon?: boolean showExternalLinkIcon?: boolean
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
read: boolean read: boolean
}>() }>()
.create(({ colorScheme, read }) => ({ .create(({ colorScheme, read }) => ({
main: { main: {
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit", fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
}, },
details: { details: {
fontSize: "90%", fontSize: "90%",
}, },
})) }))
export function FeedEntryHeader(props: FeedEntryHeaderProps) { export function FeedEntryHeader(props: FeedEntryHeaderProps) {
const { classes } = useStyles({ const { classes } = useStyles({
read: props.entry.read, read: props.entry.read,
}) })
return ( return (
<Box> <Box>
<Flex align="flex-start" justify="space-between"> <Flex align="flex-start" justify="space-between">
<Flex align="flex-start" className={classes.main}> <Flex align="flex-start" className={classes.main}>
{props.showStarIcon && ( {props.showStarIcon && (
<Box ml={-5}> <Box ml={-5}>
<Star entry={props.entry} /> <Star entry={props.entry} />
</Box> </Box>
)} )}
<FeedEntryTitle entry={props.entry} /> <FeedEntryTitle entry={props.entry} />
</Flex> </Flex>
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />} {props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
</Flex> </Flex>
<Flex align="center" className={classes.details}> <Flex align="center" className={classes.details}>
<FeedFavicon url={props.entry.iconUrl} /> <FeedFavicon url={props.entry.iconUrl} />
<Space w={6} /> <Space w={6} />
<Text c="dimmed"> <Text c="dimmed">
{props.entry.feedName} {props.entry.feedName}
<span> · </span> <span> · </span>
<RelativeDate date={props.entry.date} /> <RelativeDate date={props.entry.date} />
</Text> </Text>
</Flex> </Flex>
{props.expanded && ( {props.expanded && (
<Box className={classes.details}> <Box className={classes.details}>
<Text c="dimmed"> <Text c="dimmed">
{props.entry.author && <span>by {props.entry.author}</span>} {props.entry.author && <span>by {props.entry.author}</span>}
{props.entry.author && props.entry.categories && <span>&nbsp;·&nbsp;</span>} {props.entry.author && props.entry.categories && <span>&nbsp;·&nbsp;</span>}
{props.entry.categories && <span>{props.entry.categories}</span>} {props.entry.categories && <span>{props.entry.categories}</span>}
</Text> </Text>
</Box> </Box>
)} )}
</Box> </Box>
) )
} }

View File

@@ -1,22 +1,22 @@
import { Highlight } from "@mantine/core" import { Highlight } from "@mantine/core"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
export interface FeedEntryTitleProps { export interface FeedEntryTitleProps {
entry: Entry entry: Entry
} }
export function FeedEntryTitle(props: FeedEntryTitleProps) { export function FeedEntryTitle(props: FeedEntryTitleProps) {
const search = useAppSelector(state => state.entries.search) const search = useAppSelector(state => state.entries.search)
const keywords = search?.split(" ") const keywords = search?.split(" ")
return ( return (
<Highlight <Highlight
inherit inherit
highlight={keywords ?? ""} highlight={keywords ?? ""}
// make sure ellipsis is shown when title is too long // make sure ellipsis is shown when title is too long
span span
> >
{props.entry.title} {props.entry.title}
</Highlight> </Highlight>
) )
} }

View File

@@ -1,30 +1,30 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { ActionIcon, Anchor, Tooltip } from "@mantine/core" import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { markEntry } from "app/entries/thunks" import { markEntry } from "app/entries/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { TbExternalLink } from "react-icons/tb" import { TbExternalLink } from "react-icons/tb"
export function OpenExternalLink(props: { entry: Entry }) { export function OpenExternalLink(props: { entry: Entry }) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const onClick = (e: React.MouseEvent) => { const onClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
dispatch( dispatch(
markEntry({ markEntry({
entry: props.entry, entry: props.entry,
read: true, read: true,
}) })
) )
} }
return ( return (
<Anchor href={props.entry.url} target="_blank" rel="noreferrer" onClick={onClick}> <Anchor href={props.entry.url} target="_blank" rel="noreferrer" onClick={onClick}>
<Tooltip label={<Trans>Open link</Trans>} openDelay={Constants.tooltip.delay}> <Tooltip label={<Trans>Open link</Trans>} openDelay={Constants.tooltip.delay}>
<ActionIcon variant="transparent" c="dimmed"> <ActionIcon variant="transparent" c="dimmed">
<TbExternalLink size={18} /> <TbExternalLink size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Anchor> </Anchor>
) )
} }

View File

@@ -1,29 +1,29 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { ActionIcon, Tooltip } from "@mantine/core" import { ActionIcon, Tooltip } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { starEntry } from "app/entries/thunks" import { starEntry } from "app/entries/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import type { Entry } from "app/types" import type { Entry } from "app/types"
import { TbStar, TbStarFilled } from "react-icons/tb" import { TbStar, TbStarFilled } from "react-icons/tb"
export function Star(props: { entry: Entry }) { export function Star(props: { entry: Entry }) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const onClick = (e: React.MouseEvent) => { const onClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
dispatch( dispatch(
starEntry({ starEntry({
entry: props.entry, entry: props.entry,
starred: !props.entry.starred, starred: !props.entry.starred,
}) })
) )
} }
return ( return (
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}> <Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
<ActionIcon variant="transparent" onClick={onClick}> <ActionIcon variant="transparent" onClick={onClick}>
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />} {props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
) )
} }

View File

@@ -1,169 +1,169 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core" import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks" import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { changeReadingMode, changeReadingOrder } from "app/user/thunks" import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
import { ActionButton } from "components/ActionButton" import { ActionButton } from "components/ActionButton"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { useActionButton } from "hooks/useActionButton" import { useActionButton } from "hooks/useActionButton"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { useEffect } from "react" import { useEffect } from "react"
import { import {
TbArrowDown, TbArrowDown,
TbArrowUp, TbArrowUp,
TbExternalLink, TbExternalLink,
TbEye, TbEye,
TbEyeOff, TbEyeOff,
TbRefresh, TbRefresh,
TbSearch, TbSearch,
TbSettings, TbSettings,
TbSortAscending, TbSortAscending,
TbSortDescending, TbSortDescending,
TbUser, TbUser,
} from "react-icons/tb" } from "react-icons/tb"
import { MarkAllAsReadButton } from "./MarkAllAsReadButton" import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
import { ProfileMenu } from "./ProfileMenu" import { ProfileMenu } from "./ProfileMenu"
function HeaderDivider() { function HeaderDivider() {
return <Divider orientation="vertical" /> return <Divider orientation="vertical" />
} }
function HeaderToolbar(props: { children: React.ReactNode }) { function HeaderToolbar(props: { children: React.ReactNode }) {
const { spacing } = useActionButton() const { spacing } = useActionButton()
const mobile = useMobile("480px") const mobile = useMobile("480px")
return mobile ? ( return mobile ? (
// on mobile use all available width // on mobile use all available width
<Box <Box
style={{ style={{
width: "100%", width: "100%",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
{props.children} {props.children}
</Box> </Box>
) : ( ) : (
<Group gap={spacing}>{props.children}</Group> <Group gap={spacing}>{props.children}</Group>
) )
} }
const iconSize = 18 const iconSize = 18
export function Header() { export function Header() {
const settings = useAppSelector(state => state.user.settings) const settings = useAppSelector(state => state.user.settings)
const profile = useAppSelector(state => state.user.profile) const profile = useAppSelector(state => state.user.profile)
const searchFromStore = useAppSelector(state => state.entries.search) const searchFromStore = useAppSelector(state => state.entries.search)
const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension() const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const searchForm = useForm<{ search: string }>({ const searchForm = useForm<{ search: string }>({
validate: { validate: {
search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null), search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null),
}, },
}) })
const { setValues } = searchForm const { setValues } = searchForm
useEffect(() => { useEffect(() => {
setValues({ setValues({
search: searchFromStore, search: searchFromStore,
}) })
}, [setValues, searchFromStore]) }, [setValues, searchFromStore])
if (!settings) return <Loader /> if (!settings) return <Loader />
return ( return (
<Center> <Center>
<HeaderToolbar> <HeaderToolbar>
<ActionButton <ActionButton
icon={<TbArrowUp size={iconSize} />} icon={<TbArrowUp size={iconSize} />}
label={<Trans>Previous</Trans>} label={<Trans>Previous</Trans>}
onClick={async () => onClick={async () =>
await dispatch( await dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
/> />
<ActionButton <ActionButton
icon={<TbArrowDown size={iconSize} />} icon={<TbArrowDown size={iconSize} />}
label={<Trans>Next</Trans>} label={<Trans>Next</Trans>}
onClick={async () => onClick={async () =>
await dispatch( await dispatch(
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true, scrollToEntry: true,
}) })
) )
} }
/> />
<HeaderDivider /> <HeaderDivider />
<ActionButton <ActionButton
icon={<TbRefresh size={iconSize} />} icon={<TbRefresh size={iconSize} />}
label={<Trans>Refresh</Trans>} label={<Trans>Refresh</Trans>}
onClick={async () => await dispatch(reloadEntries())} onClick={async () => await dispatch(reloadEntries())}
/> />
<MarkAllAsReadButton iconSize={iconSize} /> <MarkAllAsReadButton iconSize={iconSize} />
<HeaderDivider /> <HeaderDivider />
<ActionButton <ActionButton
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />} icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
label={settings.readingMode === "all" ? <Trans>All</Trans> : <Trans>Unread</Trans>} label={settings.readingMode === "all" ? <Trans>All</Trans> : <Trans>Unread</Trans>}
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))} onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
/> />
<ActionButton <ActionButton
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />} icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
label={settings.readingOrder === "asc" ? <Trans>Asc</Trans> : <Trans>Desc</Trans>} label={settings.readingOrder === "asc" ? <Trans>Asc</Trans> : <Trans>Desc</Trans>}
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))} onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
/> />
<Popover> <Popover>
<Popover.Target> <Popover.Target>
<Indicator disabled={!searchFromStore}> <Indicator disabled={!searchFromStore}>
<ActionButton icon={<TbSearch size={iconSize} />} label={<Trans>Search</Trans>} /> <ActionButton icon={<TbSearch size={iconSize} />} label={<Trans>Search</Trans>} />
</Indicator> </Indicator>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}> <form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
<TextInput <TextInput
placeholder={t`Search`} placeholder={t`Search`}
{...searchForm.getInputProps("search")} {...searchForm.getInputProps("search")}
leftSection={<TbSearch size={iconSize} />} leftSection={<TbSearch size={iconSize} />}
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />} rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
autoFocus autoFocus
/> />
</form> </form>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
<HeaderDivider /> <HeaderDivider />
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} /> <ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />
{isBrowserExtensionPopup && ( {isBrowserExtensionPopup && (
<> <>
<HeaderDivider /> <HeaderDivider />
<ActionButton <ActionButton
icon={<TbSettings size={iconSize} />} icon={<TbSettings size={iconSize} />}
label={<Trans>Extension options</Trans>} label={<Trans>Extension options</Trans>}
onClick={() => openSettingsPage()} onClick={() => openSettingsPage()}
/> />
<ActionButton <ActionButton
icon={<TbExternalLink size={iconSize} />} icon={<TbExternalLink size={iconSize} />}
label={<Trans>Open CommaFeed</Trans>} label={<Trans>Open CommaFeed</Trans>}
onClick={() => openAppInNewTab()} onClick={() => openAppInNewTab()}
/> />
</> </>
)} )}
</HeaderToolbar> </HeaderToolbar>
</Center> </Center>
) )
} }

View File

@@ -1,97 +1,97 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core" import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
import { markAllEntries } from "app/entries/thunks" import { markAllEntries } from "app/entries/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { ActionButton } from "components/ActionButton" import { ActionButton } from "components/ActionButton"
import { useState } from "react" import { useState } from "react"
import { TbChecks } from "react-icons/tb" import { TbChecks } from "react-icons/tb"
export function MarkAllAsReadButton(props: { iconSize: number }) { export function MarkAllAsReadButton(props: { iconSize: number }) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const [threshold, setThreshold] = useState(0) const [threshold, setThreshold] = useState(0)
const source = useAppSelector(state => state.entries.source) const source = useAppSelector(state => state.entries.source)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel) const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now() const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation) const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const buttonClicked = () => { const buttonClicked = () => {
if (markAllAsReadConfirmation) { if (markAllAsReadConfirmation) {
setThreshold(0) setThreshold(0)
setOpened(true) setOpened(true)
} else { } else {
dispatch( dispatch(
markAllEntries({ markAllEntries({
sourceType: source.type, sourceType: source.type,
req: { req: {
id: source.id, id: source.id,
read: true, read: true,
olderThan: Date.now(), olderThan: Date.now(),
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
}, },
}) })
) )
} }
} }
return ( return (
<> <>
<Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}> <Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}>
<Stack> <Stack>
<Text size="sm"> <Text size="sm">
{threshold === 0 && ( {threshold === 0 && (
<Trans> <Trans>
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read? Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
</Trans> </Trans>
)} )}
{threshold > 0 && ( {threshold > 0 && (
<Trans> <Trans>
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read? Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
</Trans> </Trans>
)} )}
</Text> </Text>
<Slider <Slider
py="xl" py="xl"
min={0} min={0}
max={28} max={28}
marks={[ marks={[
{ value: 0, label: "0" }, { value: 0, label: "0" },
{ value: 7, label: "7" }, { value: 7, label: "7" },
{ value: 14, label: "14" }, { value: 14, label: "14" },
{ value: 21, label: "21" }, { value: 21, label: "21" },
{ value: 28, label: "28" }, { value: 28, label: "28" },
]} ]}
value={threshold} value={threshold}
onChange={setThreshold} onChange={setThreshold}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)}> <Button variant="default" onClick={() => setOpened(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setOpened(false) setOpened(false)
dispatch( dispatch(
markAllEntries({ markAllEntries({
sourceType: source.type, sourceType: source.type,
req: { req: {
id: source.id, id: source.id,
read: true, read: true,
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000, olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
}, },
}) })
) )
}} }}
> >
<Trans>Confirm</Trans> <Trans>Confirm</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
<ActionButton icon={<TbChecks size={props.iconSize} />} label={<Trans>Mark all as read</Trans>} onClick={buttonClicked} /> <ActionButton icon={<TbChecks size={props.iconSize} />} label={<Trans>Mark all as read</Trans>} onClick={buttonClicked} />
</> </>
) )
} }

View File

@@ -1,217 +1,217 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { import {
Box, Box,
Divider, Divider,
Group, Group,
type MantineColorScheme, type MantineColorScheme,
Menu, Menu,
SegmentedControl, SegmentedControl,
type SegmentedControlItem, type SegmentedControlItem,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core" } from "@mantine/core"
import { showNotification } from "@mantine/notifications" import { showNotification } from "@mantine/notifications"
import { client } from "app/client" import { client } from "app/client"
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks" import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type ViewMode } from "app/types" import type { ViewMode } from "app/types"
import { useViewMode } from "hooks/useViewMode" import { useViewMode } from "hooks/useViewMode"
import { type ReactNode, useState } from "react" import { type ReactNode, useState } from "react"
import { import {
TbChartLine, TbChartLine,
TbHeartFilled, TbHeartFilled,
TbHelp, TbHelp,
TbLayoutList, TbLayoutList,
TbList, TbList,
TbListDetails, TbListDetails,
TbMoon, TbMoon,
TbNotes, TbNotes,
TbPower, TbPower,
TbSettings, TbSettings,
TbSun, TbSun,
TbSunMoon, TbSunMoon,
TbUsers, TbUsers,
TbWorldDownload, TbWorldDownload,
} from "react-icons/tb" } from "react-icons/tb"
interface ProfileMenuProps { interface ProfileMenuProps {
control: React.ReactElement control: React.ReactElement
} }
const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => { const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => {
return ( return (
<Group> <Group>
{icon} {icon}
<Box ml={6}>{label}</Box> <Box ml={6}>{label}</Box>
</Group> </Group>
) )
} }
const iconSize = 16 const iconSize = 16
interface ColorSchemeControlItem extends SegmentedControlItem { interface ColorSchemeControlItem extends SegmentedControlItem {
value: MantineColorScheme value: MantineColorScheme
} }
const colorSchemeData: ColorSchemeControlItem[] = [ const colorSchemeData: ColorSchemeControlItem[] = [
{ {
value: "light", value: "light",
label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />, label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />,
}, },
{ {
value: "dark", value: "dark",
label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />, label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />,
}, },
{ {
value: "auto", value: "auto",
label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />, label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />,
}, },
] ]
interface ViewModeControlItem extends SegmentedControlItem { interface ViewModeControlItem extends SegmentedControlItem {
value: ViewMode value: ViewMode
} }
const viewModeData: ViewModeControlItem[] = [ const viewModeData: ViewModeControlItem[] = [
{ {
value: "title", value: "title",
label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />, label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />,
}, },
{ {
value: "cozy", value: "cozy",
label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />, label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />,
}, },
{ {
value: "detailed", value: "detailed",
label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />, label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />,
}, },
{ {
value: "expanded", value: "expanded",
label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />, label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />,
}, },
] ]
export function ProfileMenu(props: ProfileMenuProps) { export function ProfileMenu(props: ProfileMenuProps) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const { viewMode, setViewMode } = useViewMode() const { viewMode, setViewMode } = useViewMode()
const profile = useAppSelector(state => state.user.profile) const profile = useAppSelector(state => state.user.profile)
const admin = useAppSelector(state => state.user.profile?.admin) const admin = useAppSelector(state => state.user.profile?.admin)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { colorScheme, setColorScheme } = useMantineColorScheme() const { colorScheme, setColorScheme } = useMantineColorScheme()
const logout = () => { const logout = () => {
window.location.href = "logout" window.location.href = "logout"
} }
return ( return (
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}> <Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
<Menu.Target>{props.control}</Menu.Target> <Menu.Target>{props.control}</Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{profile && <Menu.Label>{profile.name}</Menu.Label>} {profile && <Menu.Label>{profile.name}</Menu.Label>}
<Menu.Item <Menu.Item
leftSection={<TbSettings size={iconSize} />} leftSection={<TbSettings size={iconSize} />}
onClick={() => { onClick={() => {
dispatch(redirectToSettings()) dispatch(redirectToSettings())
setOpened(false) setOpened(false)
}} }}
> >
<Trans>Settings</Trans> <Trans>Settings</Trans>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<TbWorldDownload size={iconSize} />} leftSection={<TbWorldDownload size={iconSize} />}
onClick={async () => onClick={async () =>
await client.feed.refreshAll().then(() => { await client.feed.refreshAll().then(() => {
showNotification({ showNotification({
message: <Trans>Your feeds have been queued for refresh.</Trans>, message: <Trans>Your feeds have been queued for refresh.</Trans>,
color: "green", color: "green",
autoClose: 1000, autoClose: 1000,
}) })
setOpened(false) setOpened(false)
}) })
} }
> >
<Trans>Fetch all my feeds now</Trans> <Trans>Fetch all my feeds now</Trans>
</Menu.Item> </Menu.Item>
<Divider /> <Divider />
<Menu.Label> <Menu.Label>
<Trans>Theme</Trans> <Trans>Theme</Trans>
</Menu.Label> </Menu.Label>
<SegmentedControl <SegmentedControl
fullWidth fullWidth
orientation="vertical" orientation="vertical"
data={colorSchemeData} data={colorSchemeData}
value={colorScheme} value={colorScheme}
onChange={e => setColorScheme(e as MantineColorScheme)} onChange={e => setColorScheme(e as MantineColorScheme)}
mb="xs" mb="xs"
/> />
<Divider /> <Divider />
<Menu.Label> <Menu.Label>
<Trans>Display</Trans> <Trans>Display</Trans>
</Menu.Label> </Menu.Label>
<SegmentedControl <SegmentedControl
fullWidth fullWidth
orientation="vertical" orientation="vertical"
data={viewModeData} data={viewModeData}
value={viewMode} value={viewMode}
onChange={e => setViewMode(e as ViewMode)} onChange={e => setViewMode(e as ViewMode)}
mb="xs" mb="xs"
/> />
{admin && ( {admin && (
<> <>
<Divider /> <Divider />
<Menu.Label> <Menu.Label>
<Trans>Admin</Trans> <Trans>Admin</Trans>
</Menu.Label> </Menu.Label>
<Menu.Item <Menu.Item
leftSection={<TbUsers size={iconSize} />} leftSection={<TbUsers size={iconSize} />}
onClick={() => { onClick={() => {
dispatch(redirectToAdminUsers()) dispatch(redirectToAdminUsers())
setOpened(false) setOpened(false)
}} }}
> >
<Trans>Manage users</Trans> <Trans>Manage users</Trans>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<TbChartLine size={iconSize} />} leftSection={<TbChartLine size={iconSize} />}
onClick={() => { onClick={() => {
dispatch(redirectToMetrics()) dispatch(redirectToMetrics())
setOpened(false) setOpened(false)
}} }}
> >
<Trans>Metrics</Trans> <Trans>Metrics</Trans>
</Menu.Item> </Menu.Item>
</> </>
)} )}
<Divider /> <Divider />
<Menu.Item <Menu.Item
leftSection={<TbHeartFilled size={iconSize} color="red" />} leftSection={<TbHeartFilled size={iconSize} color="red" />}
onClick={() => { onClick={() => {
dispatch(redirectToDonate()) dispatch(redirectToDonate())
setOpened(false) setOpened(false)
}} }}
> >
<Trans>Donate</Trans> <Trans>Donate</Trans>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<TbHelp size={iconSize} />} leftSection={<TbHelp size={iconSize} />}
onClick={() => { onClick={() => {
dispatch(redirectToAbout()) dispatch(redirectToAbout())
setOpened(false) setOpened(false)
}} }}
> >
<Trans>About</Trans> <Trans>About</Trans>
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}> <Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}>
<Trans>Logout</Trans> <Trans>Logout</Trans>
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
) )
} }

View File

@@ -1,9 +1,9 @@
import { type MetricGauge } from "app/types" import type { MetricGauge } from "app/types"
interface MeterProps { interface MeterProps {
gauge: MetricGauge gauge: MetricGauge
} }
export function Gauge(props: MeterProps) { export function Gauge(props: MeterProps) {
return <span>{props.gauge.value}</span> return <span>{props.gauge.value}</span>
} }

View File

@@ -1,19 +1,19 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { type MetricMeter } from "app/types" import type { MetricMeter } from "app/types"
interface MeterProps { interface MeterProps {
meter: MetricMeter meter: MetricMeter
} }
export function Meter(props: MeterProps) { export function Meter(props: MeterProps) {
return ( return (
<Box> <Box>
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box> <Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
<Box>Last minute: {props.meter.m1_rate.toFixed(2)}</Box> <Box>Last minute: {props.meter.m1_rate.toFixed(2)}</Box>
<Box>Last 5 minutes: {props.meter.m5_rate.toFixed(2)}</Box> <Box>Last 5 minutes: {props.meter.m5_rate.toFixed(2)}</Box>
<Box>Last 15 minutes: {props.meter.m15_rate.toFixed(2)}</Box> <Box>Last 15 minutes: {props.meter.m15_rate.toFixed(2)}</Box>
<Box>Units: {props.meter.units}</Box> <Box>Units: {props.meter.units}</Box>
<Box>Total: {props.meter.count}</Box> <Box>Total: {props.meter.count}</Box>
</Box> </Box>
) )
} }

View File

@@ -1,22 +1,22 @@
import { Accordion, Box, Group } from "@mantine/core" import { Accordion, Box, Group } from "@mantine/core"
interface MetricAccordionItemProps { interface MetricAccordionItemProps {
metricKey: string metricKey: string
name: string name: string
headerValue: number headerValue: number
children: React.ReactNode children: React.ReactNode
} }
export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) { export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) {
return ( return (
<Accordion.Item value={metricKey} key={metricKey}> <Accordion.Item value={metricKey} key={metricKey}>
<Accordion.Control> <Accordion.Control>
<Group justify="space-between"> <Group justify="space-between">
<Box>{name}</Box> <Box>{name}</Box>
<Box>{headerValue}</Box> <Box>{headerValue}</Box>
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel>{children}</Accordion.Panel> <Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item> </Accordion.Item>
) )
} }

View File

@@ -1,19 +1,19 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { type MetricTimer } from "app/types" import type { MetricTimer } from "app/types"
interface MetricTimerProps { interface MetricTimerProps {
timer: MetricTimer timer: MetricTimer
} }
export function Timer(props: MetricTimerProps) { export function Timer(props: MetricTimerProps) {
return ( return (
<Box> <Box>
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box> <Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
<Box>Last minute: {props.timer.m1_rate.toFixed(2)}</Box> <Box>Last minute: {props.timer.m1_rate.toFixed(2)}</Box>
<Box>Last 5 minutes: {props.timer.m5_rate.toFixed(2)}</Box> <Box>Last 5 minutes: {props.timer.m5_rate.toFixed(2)}</Box>
<Box>Last 15 minutes: {props.timer.m15_rate.toFixed(2)}</Box> <Box>Last 15 minutes: {props.timer.m15_rate.toFixed(2)}</Box>
<Box>Units: {props.timer.rate_units}</Box> <Box>Units: {props.timer.rate_units}</Box>
<Box>Total: {props.timer.count}</Box> <Box>Total: {props.timer.count}</Box>
</Box> </Box>
) )
} }

View File

@@ -1,8 +1,8 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import React from "react" import type React from "react"
export function OnDesktop(props: { children: React.ReactNode }) { export function OnDesktop(props: { children: React.ReactNode }) {
const mobile = useMobile() const mobile = useMobile()
return <Box>{!mobile && props.children}</Box> return <Box>{!mobile && props.children}</Box>
} }

View File

@@ -1,8 +1,8 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import React from "react" import type React from "react"
export function OnMobile(props: { children: React.ReactNode }) { export function OnMobile(props: { children: React.ReactNode }) {
const mobile = useMobile() const mobile = useMobile()
return <Box>{mobile && props.children}</Box> return <Box>{mobile && props.children}</Box>
} }

View File

@@ -1,161 +1,161 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Divider, Group, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core" import { Divider, Group, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
import { type ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types" import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type IconDisplayMode, type ScrollMode, type SharingSettings } from "app/types" import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
import { import {
changeCustomContextMenu, changeCustomContextMenu,
changeExternalLinkIconDisplayMode, changeExternalLinkIconDisplayMode,
changeLanguage, changeLanguage,
changeMarkAllAsReadConfirmation, changeMarkAllAsReadConfirmation,
changeMobileFooter, changeMobileFooter,
changeScrollMarks, changeScrollMarks,
changeScrollMode, changeScrollMode,
changeScrollSpeed, changeScrollSpeed,
changeSharingSetting, changeSharingSetting,
changeShowRead, changeShowRead,
changeStarIconDisplayMode, changeStarIconDisplayMode,
} from "app/user/thunks" } from "app/user/thunks"
import { locales } from "i18n" import { locales } from "i18n"
import { type ReactNode } from "react" import type { ReactNode } from "react"
export function DisplaySettings() { export function DisplaySettings() {
const language = useAppSelector(state => state.user.settings?.language) const language = useAppSelector(state => state.user.settings?.language)
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed) const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
const showRead = useAppSelector(state => state.user.settings?.showRead) const showRead = useAppSelector(state => state.user.settings?.showRead)
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks) const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode) const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode) const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode) const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation) const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu) const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter) const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const scrollModeOptions: Record<ScrollMode, ReactNode> = { const scrollModeOptions: Record<ScrollMode, ReactNode> = {
always: <Trans>Always</Trans>, always: <Trans>Always</Trans>,
never: <Trans>Never</Trans>, never: <Trans>Never</Trans>,
if_needed: <Trans>If the entry doesn't entirely fit on the screen</Trans>, if_needed: <Trans>If the entry doesn't entirely fit on the screen</Trans>,
} }
const displayModeData: ComboboxData = [ const displayModeData: ComboboxData = [
{ {
value: "always", value: "always",
label: t`Always`, label: t`Always`,
}, },
{ {
value: "on_desktop", value: "on_desktop",
label: t`On desktop`, label: t`On desktop`,
}, },
{ {
value: "on_mobile", value: "on_mobile",
label: t`On mobile`, label: t`On mobile`,
}, },
{ {
value: "never", value: "never",
label: t`Never`, label: t`Never`,
}, },
] ]
return ( return (
<Stack> <Stack>
<Select <Select
description={<Trans>Language</Trans>} description={<Trans>Language</Trans>}
value={language} value={language}
data={locales.map(l => ({ data={locales.map(l => ({
value: l.key, value: l.key,
label: l.label, label: l.label,
}))} }))}
onChange={async s => await (s && dispatch(changeLanguage(s)))} onChange={async s => await (s && dispatch(changeLanguage(s)))}
/> />
<Switch <Switch
label={<Trans>Show feeds and categories with no unread entries</Trans>} label={<Trans>Show feeds and categories with no unread entries</Trans>}
checked={showRead} checked={showRead}
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))} onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
/> />
<Switch <Switch
label={<Trans>Show confirmation when marking all entries as read</Trans>} label={<Trans>Show confirmation when marking all entries as read</Trans>}
checked={markAllAsReadConfirmation} checked={markAllAsReadConfirmation}
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))} onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
/> />
<Switch <Switch
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>} label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
checked={mobileFooter} checked={mobileFooter}
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))} onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
/> />
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" /> <Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
<Select <Select
description={<Trans>Show star icon</Trans>} description={<Trans>Show star icon</Trans>}
value={starIconDisplayMode} value={starIconDisplayMode}
data={displayModeData} data={displayModeData}
onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))} onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
/> />
<Select <Select
description={<Trans>Show external link icon</Trans>} description={<Trans>Show external link icon</Trans>}
value={externalLinkIconDisplayMode} value={externalLinkIconDisplayMode}
data={displayModeData} data={displayModeData}
onChange={async s => await dispatch(changeExternalLinkIconDisplayMode(s as IconDisplayMode))} onChange={async s => await dispatch(changeExternalLinkIconDisplayMode(s as IconDisplayMode))}
/> />
<Switch <Switch
label={<Trans>Show CommaFeed's own context menu on right click</Trans>} label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
checked={customContextMenu} checked={customContextMenu}
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))} onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
/> />
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" /> <Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
<Radio.Group <Radio.Group
label={<Trans>Scroll selected entry to the top of the page</Trans>} label={<Trans>Scroll selected entry to the top of the page</Trans>}
value={scrollMode} value={scrollMode}
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))} onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))}
> >
<Group mt="xs"> <Group mt="xs">
{Object.entries(scrollModeOptions).map(e => ( {Object.entries(scrollModeOptions).map(e => (
<Radio key={e[0]} value={e[0]} label={e[1]} /> <Radio key={e[0]} value={e[0]} label={e[1]} />
))} ))}
</Group> </Group>
</Radio.Group> </Radio.Group>
<Switch <Switch
label={<Trans>Scroll smoothly when navigating between entries</Trans>} label={<Trans>Scroll smoothly when navigating between entries</Trans>}
checked={scrollSpeed ? scrollSpeed > 0 : false} checked={scrollSpeed ? scrollSpeed > 0 : false}
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))} onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
/> />
<Switch <Switch
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>} label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
checked={scrollMarks} checked={scrollMarks}
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))} onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
/> />
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" /> <Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
{(Object.keys(Constants.sharing) as Array<keyof SharingSettings>).map(site => ( {(Object.keys(Constants.sharing) as Array<keyof SharingSettings>).map(site => (
<Switch <Switch
key={site} key={site}
label={Constants.sharing[site].label} label={Constants.sharing[site].label}
checked={sharingSettings?.[site]} checked={sharingSettings?.[site]}
onChange={async e => onChange={async e =>
await dispatch( await dispatch(
changeSharingSetting({ changeSharingSetting({
site, site,
value: e.currentTarget.checked, value: e.currentTarget.checked,
}) })
) )
} }
/> />
))} ))}
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
) )
} }

View File

@@ -1,162 +1,162 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core" import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals" import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks" import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type ProfileModificationRequest } from "app/types" import type { ProfileModificationRequest } from "app/types"
import { reloadProfile } from "app/user/thunks" import { reloadProfile } from "app/user/thunks"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useEffect } from "react" import { useEffect } from "react"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb" import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
interface FormData extends ProfileModificationRequest { interface FormData extends ProfileModificationRequest {
newPasswordConfirmation?: string newPasswordConfirmation?: string
} }
export function ProfileSettings() { export function ProfileSettings() {
const profile = useAppSelector(state => state.user.profile) const profile = useAppSelector(state => state.user.profile)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const form = useForm<FormData>({ const form = useForm<FormData>({
validate: { validate: {
newPasswordConfirmation: (value, values) => (value !== values.newPassword ? t`Passwords do not match` : null), newPasswordConfirmation: (value, values) => (value !== values.newPassword ? t`Passwords do not match` : null),
}, },
}) })
const { setValues } = form const { setValues } = form
const saveProfile = useAsyncCallback(client.user.saveProfile, { const saveProfile = useAsyncCallback(client.user.saveProfile, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadProfile()) dispatch(reloadProfile())
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
}, },
}) })
const deleteProfile = useAsyncCallback(client.user.deleteProfile, { const deleteProfile = useAsyncCallback(client.user.deleteProfile, {
onSuccess: () => { onSuccess: () => {
dispatch(redirectToLogin()) dispatch(redirectToLogin())
}, },
}) })
const openDeleteProfileModal = () => const openDeleteProfileModal = () =>
openConfirmModal({ openConfirmModal({
title: <Trans>Delete account</Trans>, title: <Trans>Delete account</Trans>,
children: ( children: (
<Text size="sm"> <Text size="sm">
<Trans>Are you sure you want to delete your account? There's no turning back!</Trans> <Trans>Are you sure you want to delete your account? There's no turning back!</Trans>
</Text> </Text>
), ),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> }, labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm: async () => await deleteProfile.execute(), onConfirm: async () => await deleteProfile.execute(),
}) })
useEffect(() => { useEffect(() => {
if (!profile) return if (!profile) return
setValues({ setValues({
currentPassword: "", currentPassword: "",
email: profile.email ?? "", email: profile.email ?? "",
newApiKey: false, newApiKey: false,
}) })
}, [setValues, profile]) }, [setValues, profile])
return ( return (
<> <>
{saveProfile.error && ( {saveProfile.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(saveProfile.error)} /> <Alert messages={errorToStrings(saveProfile.error)} />
</Box> </Box>
)} )}
{deleteProfile.error && ( {deleteProfile.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(deleteProfile.error)} /> <Alert messages={errorToStrings(deleteProfile.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(saveProfile.execute)}> <form onSubmit={form.onSubmit(saveProfile.execute)}>
<Stack> <Stack>
<TextInput label={<Trans>User name</Trans>} readOnly value={profile?.name} /> <TextInput label={<Trans>User name</Trans>} readOnly value={profile?.name} />
<TextInput <TextInput
label={<Trans>API key</Trans>} label={<Trans>API key</Trans>}
description={ description={
<Trans> <Trans>
This is your API key. It can be used for some read-only API operations and grants access to the Fever API. This is your API key. It can be used for some read-only API operations and grants access to the Fever API.
Use the form at the bottom of the page to generate a new API key Use the form at the bottom of the page to generate a new API key
</Trans> </Trans>
} }
readOnly readOnly
value={profile?.apiKey} value={profile?.apiKey}
/> />
<Input.Wrapper <Input.Wrapper
label={<Trans>OPML export</Trans>} label={<Trans>OPML export</Trans>}
description={ description={
<Trans> <Trans>
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
</Trans> </Trans>
} }
> >
<Box> <Box>
<Anchor href="rest/feed/export" download="commafeed.opml"> <Anchor href="rest/feed/export" download="commafeed.opml">
<Trans>Download</Trans> <Trans>Download</Trans>
</Anchor> </Anchor>
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Input.Wrapper <Input.Wrapper
label={<Trans>Fever API</Trans>} label={<Trans>Fever API</Trans>}
description={ description={
<Trans> <Trans>
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
Login with your username and your <u>API key</u>. Login with your username and your <u>API key</u>.
</Trans> </Trans>
} }
> >
<Box> <Box>
<Anchor href={`rest/fever/user/${profile?.id}`} target="_blank"> <Anchor href={`rest/fever/user/${profile?.id}`} target="_blank">
<Trans>Fever API URL</Trans> <Trans>Fever API URL</Trans>
</Anchor> </Anchor>
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Divider /> <Divider />
<PasswordInput <PasswordInput
label={<Trans>Current password</Trans>} label={<Trans>Current password</Trans>}
description={<Trans>Enter your current password to change profile settings</Trans>} description={<Trans>Enter your current password to change profile settings</Trans>}
required required
{...form.getInputProps("currentPassword")} {...form.getInputProps("currentPassword")}
/> />
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required /> <TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required />
<PasswordInput <PasswordInput
label={<Trans>New password</Trans>} label={<Trans>New password</Trans>}
description={<Trans>Changing password will generate a new API key</Trans>} description={<Trans>Changing password will generate a new API key</Trans>}
{...form.getInputProps("newPassword")} {...form.getInputProps("newPassword")}
/> />
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} /> <PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} /> <Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
<Group> <Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveProfile.loading}> <Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveProfile.loading}>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Button <Button
color="red" color="red"
leftSection={<TbTrash size={16} />} leftSection={<TbTrash size={16} />}
onClick={() => openDeleteProfileModal()} onClick={() => openDeleteProfileModal()}
loading={deleteProfile.loading} loading={deleteProfile.loading}
> >
<Trans>Delete account</Trans> <Trans>Delete account</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</> </>
) )
} }

View File

@@ -1,175 +1,175 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Stack } from "@mantine/core" import { Box, Stack } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { import {
redirectToCategory, redirectToCategory,
redirectToCategoryDetails, redirectToCategoryDetails,
redirectToFeed, redirectToFeed,
redirectToFeedDetails, redirectToFeedDetails,
redirectToTag, redirectToTag,
redirectToTagDetails, redirectToTagDetails,
} from "app/redirect/thunks" } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { collapseTreeCategory } from "app/tree/thunks" import { collapseTreeCategory } from "app/tree/thunks"
import { type Category, type Subscription } from "app/types" import type { Category, Subscription } from "app/types"
import { categoryUnreadCount, flattenCategoryTree } from "app/utils" import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { OnDesktop } from "components/responsive/OnDesktop" import { OnDesktop } from "components/responsive/OnDesktop"
import React from "react" import React from "react"
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb" import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { TreeNode } from "./TreeNode" import { TreeNode } from "./TreeNode"
import { TreeSearch } from "./TreeSearch" import { TreeSearch } from "./TreeSearch"
const allIcon = <TbInbox size={16} /> const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} /> const starredIcon = <TbStar size={16} />
const tagIcon = <TbTag size={16} /> const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} /> const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} /> const collapsedIcon = <TbChevronRight size={16} />
const errorThreshold = 9 const errorThreshold = 9
export function Tree() { export function Tree() {
const root = useAppSelector(state => state.tree.rootCategory) const root = useAppSelector(state => state.tree.rootCategory)
const source = useAppSelector(state => state.entries.source) const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector(state => state.user.tags) const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector(state => state.user.settings?.showRead) const showRead = useAppSelector(state => state.user.settings?.showRead)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const feedClicked = (e: React.MouseEvent, id: string) => { const feedClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) { if (e.detail === 2) {
dispatch(redirectToFeedDetails(id)) dispatch(redirectToFeedDetails(id))
} else { } else {
dispatch(redirectToFeed(id)) dispatch(redirectToFeed(id))
} }
} }
const categoryClicked = (e: React.MouseEvent, id: string) => { const categoryClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) { if (e.detail === 2) {
dispatch(redirectToCategoryDetails(id)) dispatch(redirectToCategoryDetails(id))
} else { } else {
dispatch(redirectToCategory(id)) dispatch(redirectToCategory(id))
} }
} }
const categoryIconClicked = (e: React.MouseEvent, category: Category) => { const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation() e.stopPropagation()
dispatch( dispatch(
collapseTreeCategory({ collapseTreeCategory({
id: +category.id, id: +category.id,
collapse: category.expanded, collapse: category.expanded,
}) })
) )
} }
const tagClicked = (e: React.MouseEvent, id: string) => { const tagClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) { if (e.detail === 2) {
dispatch(redirectToTagDetails(id)) dispatch(redirectToTagDetails(id))
} else { } else {
dispatch(redirectToTag(id)) dispatch(redirectToTag(id))
} }
} }
const allCategoryNode = () => ( const allCategoryNode = () => (
<TreeNode <TreeNode
id={Constants.categories.all.id} id={Constants.categories.all.id}
name={<Trans>All</Trans>} name={<Trans>All</Trans>}
icon={allIcon} icon={allIcon}
unread={categoryUnreadCount(root)} unread={categoryUnreadCount(root)}
selected={source.type === "category" && source.id === Constants.categories.all.id} selected={source.type === "category" && source.id === Constants.categories.all.id}
expanded={false} expanded={false}
level={0} level={0}
hasError={false} hasError={false}
onClick={categoryClicked} onClick={categoryClicked}
/> />
) )
const starredCategoryNode = () => ( const starredCategoryNode = () => (
<TreeNode <TreeNode
id={Constants.categories.starred.id} id={Constants.categories.starred.id}
name={<Trans>Starred</Trans>} name={<Trans>Starred</Trans>}
icon={starredIcon} icon={starredIcon}
unread={0} unread={0}
selected={source.type === "category" && source.id === Constants.categories.starred.id} selected={source.type === "category" && source.id === Constants.categories.starred.id}
expanded={false} expanded={false}
level={0} level={0}
hasError={false} hasError={false}
onClick={categoryClicked} onClick={categoryClicked}
/> />
) )
const categoryNode = (category: Category, level = 0) => { const categoryNode = (category: Category, level = 0) => {
const unreadCount = categoryUnreadCount(category) const unreadCount = categoryUnreadCount(category)
if (unreadCount === 0 && !showRead) return null if (unreadCount === 0 && !showRead) return null
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold)) const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
return ( return (
<TreeNode <TreeNode
id={category.id} id={category.id}
name={category.name} name={category.name}
icon={category.expanded ? expandedIcon : collapsedIcon} icon={category.expanded ? expandedIcon : collapsedIcon}
unread={unreadCount} unread={unreadCount}
selected={source.type === "category" && source.id === category.id} selected={source.type === "category" && source.id === category.id}
expanded={category.expanded} expanded={category.expanded}
level={level} level={level}
hasError={hasError} hasError={hasError}
onClick={categoryClicked} onClick={categoryClicked}
onIconClick={e => categoryIconClicked(e, category)} onIconClick={e => categoryIconClicked(e, category)}
key={category.id} key={category.id}
/> />
) )
} }
const feedNode = (feed: Subscription, level = 0) => { const feedNode = (feed: Subscription, level = 0) => {
if (feed.unread === 0 && !showRead) return null if (feed.unread === 0 && !showRead) return null
return ( return (
<TreeNode <TreeNode
id={String(feed.id)} id={String(feed.id)}
name={feed.name} name={feed.name}
icon={feed.iconUrl} icon={feed.iconUrl}
unread={feed.unread} unread={feed.unread}
selected={source.type === "feed" && source.id === String(feed.id)} selected={source.type === "feed" && source.id === String(feed.id)}
level={level} level={level}
hasError={feed.errorCount > errorThreshold} hasError={feed.errorCount > errorThreshold}
onClick={feedClicked} onClick={feedClicked}
key={feed.id} key={feed.id}
/> />
) )
} }
const tagNode = (tag: string) => ( const tagNode = (tag: string) => (
<TreeNode <TreeNode
id={tag} id={tag}
name={tag} name={tag}
icon={tagIcon} icon={tagIcon}
unread={0} unread={0}
selected={source.type === "tag" && source.id === tag} selected={source.type === "tag" && source.id === tag}
level={0} level={0}
hasError={false} hasError={false}
onClick={tagClicked} onClick={tagClicked}
key={tag} key={tag}
/> />
) )
const recursiveCategoryNode = (category: Category, level = 0) => ( const recursiveCategoryNode = (category: Category, level = 0) => (
<React.Fragment key={`recursiveCategoryNode-${category.id}`}> <React.Fragment key={`recursiveCategoryNode-${category.id}`}>
{categoryNode(category, level)} {categoryNode(category, level)}
{category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))} {category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))}
{category.expanded && category.feeds.map(f => feedNode(f, level + 1))} {category.expanded && category.feeds.map(f => feedNode(f, level + 1))}
</React.Fragment> </React.Fragment>
) )
if (!root) return <Loader /> if (!root) return <Loader />
const feeds = flattenCategoryTree(root).flatMap(c => c.feeds) const feeds = flattenCategoryTree(root).flatMap(c => c.feeds)
return ( return (
<Stack> <Stack>
<OnDesktop> <OnDesktop>
<TreeSearch feeds={feeds} /> <TreeSearch feeds={feeds} />
</OnDesktop> </OnDesktop>
<Box> <Box>
{allCategoryNode()} {allCategoryNode()}
{starredCategoryNode()} {starredCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))} {root.children.map(c => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))} {root.feeds.map(f => feedNode(f))}
{tags?.map(tag => tagNode(tag))} {tags?.map(tag => tagNode(tag))}
</Box> </Box>
</Stack> </Stack>
) )
} }

View File

@@ -1,78 +1,78 @@
import { Box, Center } from "@mantine/core" import { Box, Center } from "@mantine/core"
import { FeedFavicon } from "components/content/FeedFavicon" import { FeedFavicon } from "components/content/FeedFavicon"
import React, { type ReactNode } from "react" import type React from "react"
import { tss } from "tss" import { tss } from "tss"
import { UnreadCount } from "./UnreadCount" import { UnreadCount } from "./UnreadCount"
interface TreeNodeProps { interface TreeNodeProps {
id: string id: string
name: ReactNode name: React.ReactNode
icon: ReactNode icon: React.ReactNode
unread: number unread: number
selected: boolean selected: boolean
expanded?: boolean expanded?: boolean
level: number level: number
hasError: boolean hasError: boolean
onClick: (e: React.MouseEvent, id: string) => void onClick: (e: React.MouseEvent, id: string) => void
onIconClick?: (e: React.MouseEvent, id: string) => void onIconClick?: (e: React.MouseEvent, id: string) => void
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
selected: boolean selected: boolean
hasError: boolean hasError: boolean
hasUnread: boolean hasUnread: boolean
}>() }>()
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => { .create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
let backgroundColor = "inherit" let backgroundColor = "inherit"
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1] if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
let color let color: string
if (hasError) { if (hasError) {
color = theme.colors.red[6] color = theme.colors.red[6]
} else if (colorScheme === "dark") { } else if (colorScheme === "dark") {
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3] color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
} else { } else {
color = hasUnread ? theme.black : theme.colors.gray[6] color = hasUnread ? theme.black : theme.colors.gray[6]
} }
return { return {
node: { node: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
cursor: "pointer", cursor: "pointer",
color, color,
backgroundColor, backgroundColor,
"&:hover": { "&:hover": {
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0], backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
}, },
}, },
nodeText: { nodeText: {
flexGrow: 1, flexGrow: 1,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
}, },
} }
}) })
export function TreeNode(props: TreeNodeProps) { export function TreeNode(props: TreeNodeProps) {
const { classes } = useStyles({ const { classes } = useStyles({
selected: props.selected, selected: props.selected,
hasError: props.hasError, hasError: props.hasError,
hasUnread: props.unread > 0, hasUnread: props.unread > 0,
}) })
return ( return (
<Box py={1} pl={props.level * 20} className={classes.node} onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}> <Box py={1} pl={props.level * 20} className={classes.node} onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}>
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}> <Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}>
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center> <Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
</Box> </Box>
<Box className={classes.nodeText}>{props.name}</Box> <Box className={classes.nodeText}>{props.name}</Box>
{!props.expanded && ( {!props.expanded && (
<Box> <Box>
<UnreadCount unreadCount={props.unread} /> <UnreadCount unreadCount={props.unread} />
</Box> </Box>
)} )}
</Box> </Box>
) )
} }

View File

@@ -1,69 +1,69 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Box, Center, Kbd, TextInput } from "@mantine/core" import { Box, Center, Kbd, TextInput } from "@mantine/core"
import { useOs } from "@mantine/hooks" import { useOs } from "@mantine/hooks"
import { Spotlight, spotlight, type SpotlightActionData } from "@mantine/spotlight" import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
import { redirectToFeed } from "app/redirect/thunks" import { redirectToFeed } from "app/redirect/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import { type Subscription } from "app/types" import type { Subscription } from "app/types"
import { FeedFavicon } from "components/content/FeedFavicon" import { FeedFavicon } from "components/content/FeedFavicon"
import { useMousetrap } from "hooks/useMousetrap" import { useMousetrap } from "hooks/useMousetrap"
import { TbSearch } from "react-icons/tb" import { TbSearch } from "react-icons/tb"
export interface TreeSearchProps { export interface TreeSearchProps {
feeds: Subscription[] feeds: Subscription[]
} }
export function TreeSearch(props: TreeSearchProps) { export function TreeSearch(props: TreeSearchProps) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const isMacOS = useOs() === "macos" const isMacOS = useOs() === "macos"
const actions: SpotlightActionData[] = props.feeds const actions: SpotlightActionData[] = props.feeds
.map(f => ({ .map(f => ({
id: `${f.id}`, id: `${f.id}`,
label: f.name, label: f.name,
leftSection: <FeedFavicon url={f.iconUrl} />, leftSection: <FeedFavicon url={f.iconUrl} />,
onClick: async () => await dispatch(redirectToFeed(f.id)), onClick: async () => await dispatch(redirectToFeed(f.id)),
})) }))
.sort((f1, f2) => f1.label.localeCompare(f2.label)) .sort((f1, f2) => f1.label.localeCompare(f2.label))
const searchIcon = <TbSearch size={18} /> const searchIcon = <TbSearch size={18} />
const rightSection = ( const rightSection = (
<Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}> <Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}>
<Kbd>{isMacOS ? "Cmd" : "Ctrl"}</Kbd> <Kbd>{isMacOS ? "Cmd" : "Ctrl"}</Kbd>
<Box mx={5}>+</Box> <Box mx={5}>+</Box>
<Kbd>K</Kbd> <Kbd>K</Kbd>
</Center> </Center>
) )
// additional keyboard shortcut used by commafeed v1 // additional keyboard shortcut used by commafeed v1
useMousetrap("g u", () => spotlight.open()) useMousetrap("g u", () => spotlight.open())
return ( return (
<> <>
<TextInput <TextInput
placeholder={t`Search`} placeholder={t`Search`}
leftSection={searchIcon} leftSection={searchIcon}
rightSectionWidth={100} rightSectionWidth={100}
rightSection={rightSection} rightSection={rightSection}
styles={{ styles={{
input: { input: {
cursor: "pointer", cursor: "pointer",
}, },
}} }}
onClick={() => spotlight.open()} onClick={() => spotlight.open()}
// prevent focus // prevent focus
onFocus={e => e.target.blur()} onFocus={e => e.target.blur()}
readOnly readOnly
/> />
<Spotlight <Spotlight
actions={actions} actions={actions}
limit={10} limit={10}
shortcut="mod+k" shortcut="mod+k"
searchProps={{ searchProps={{
leftSection: searchIcon, leftSection: searchIcon,
placeholder: t`Search`, placeholder: t`Search`,
}} }}
nothingFound={<Trans>Nothing found</Trans>} nothingFound={<Trans>Nothing found</Trans>}
></Spotlight> />
</> </>
) )
} }

View File

@@ -1,26 +1,26 @@
import { Badge, Tooltip } from "@mantine/core" import { Badge, Tooltip } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { tss } from "tss" import { tss } from "tss"
const useStyles = tss.create(() => ({ const useStyles = tss.create(() => ({
badge: { badge: {
width: "3.2rem", width: "3.2rem",
// for some reason, mantine Badge has "cursor: 'default'" // for some reason, mantine Badge has "cursor: 'default'"
cursor: "pointer", cursor: "pointer",
}, },
})) }))
export function UnreadCount(props: { unreadCount: number }) { export function UnreadCount(props: { unreadCount: number }) {
const { classes } = useStyles() const { classes } = useStyles()
if (props.unreadCount <= 0) return null if (props.unreadCount <= 0) return null
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
return ( return (
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}> <Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
<Badge className={classes.badge} variant="light"> <Badge className={classes.badge} variant="light">
{count} {count}
</Badge> </Badge>
</Tooltip> </Tooltip>
) )
} }

View File

@@ -1,9 +1,9 @@
import { useMantineTheme } from "@mantine/core" import { useMantineTheme } from "@mantine/core"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
export const useActionButton = () => { export const useActionButton = () => {
const theme = useMantineTheme() const theme = useMantineTheme()
const mobile = useMobile(theme.breakpoints.xl) const mobile = useMobile(theme.breakpoints.xl)
const spacing = mobile ? 14 : 0 const spacing = mobile ? 14 : 0
return { mobile, spacing } return { mobile, spacing }
} }

View File

@@ -1,39 +1,39 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
interface Step { interface Step {
label: string label: string
done: boolean done: boolean
} }
export const useAppLoading = () => { export const useAppLoading = () => {
const profile = useAppSelector(state => state.user.profile) const profile = useAppSelector(state => state.user.profile)
const settings = useAppSelector(state => state.user.settings) const settings = useAppSelector(state => state.user.settings)
const rootCategory = useAppSelector(state => state.tree.rootCategory) const rootCategory = useAppSelector(state => state.tree.rootCategory)
const tags = useAppSelector(state => state.user.tags) const tags = useAppSelector(state => state.user.tags)
const steps: Step[] = [ const steps: Step[] = [
{ {
label: t`Loading settings...`, label: t`Loading settings...`,
done: !!settings, done: !!settings,
}, },
{ {
label: t`Loading profile...`, label: t`Loading profile...`,
done: !!profile, done: !!profile,
}, },
{ {
label: t`Loading subscriptions...`, label: t`Loading subscriptions...`,
done: !!rootCategory, done: !!rootCategory,
}, },
{ {
label: t`Loading tags...`, label: t`Loading tags...`,
done: !!tags, done: !!tags,
}, },
] ]
const loading = steps.some(s => !s.done) const loading = steps.some(s => !s.done)
const loadingPercentage = Math.round((100.0 * steps.filter(s => s.done).length) / steps.length) const loadingPercentage = Math.round((100.0 * steps.filter(s => s.done).length) / steps.length)
const loadingStepLabel = steps.find(s => !s.done)?.label const loadingStepLabel = steps.find(s => !s.done)?.label
return { steps, loading, loadingPercentage, loadingStepLabel } return { steps, loading, loadingPercentage, loadingStepLabel }
} }

View File

@@ -1,64 +1,64 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
export const useBrowserExtension = () => { export const useBrowserExtension = () => {
// the extension will set the "browser-extension-installed" attribute on the root element // the extension will set the "browser-extension-installed" attribute on the root element
const [browserExtensionVersion, setBrowserExtensionVersion] = useState( const [browserExtensionVersion, setBrowserExtensionVersion] = useState(
document.documentElement.getAttribute("browser-extension-installed") document.documentElement.getAttribute("browser-extension-installed")
) )
// monitor the attribute on the root element as it may change after the page was loaded // monitor the attribute on the root element as it may change after the page was loaded
useEffect(() => { useEffect(() => {
const observer = new MutationObserver(mutations => { const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => { for (const mutation of mutations) {
if (mutation.type === "attributes") { if (mutation.type === "attributes") {
const element = mutation.target as Element const element = mutation.target as Element
const version = element.getAttribute("browser-extension-installed") const version = element.getAttribute("browser-extension-installed")
if (version) setBrowserExtensionVersion(version) if (version) setBrowserExtensionVersion(version)
} }
}) }
}) })
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
}) })
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
// when not in an iframe, window.parent is a reference to window // when not in an iframe, window.parent is a reference to window
const isBrowserExtensionPopup = window.parent !== window const isBrowserExtensionPopup = window.parent !== window
const isBrowserExtensionInstalled = isBrowserExtensionPopup || !!browserExtensionVersion const isBrowserExtensionInstalled = isBrowserExtensionPopup || !!browserExtensionVersion
const isBrowserExtensionInstallable = !isBrowserExtensionPopup const isBrowserExtensionInstallable = !isBrowserExtensionPopup
const w = isBrowserExtensionPopup ? window.parent : window const w = isBrowserExtensionPopup ? window.parent : window
const openSettingsPage = () => w.postMessage("open-settings-page", "*") const openSettingsPage = () => w.postMessage("open-settings-page", "*")
const openAppInNewTab = () => w.postMessage("open-app-in-new-tab", "*") const openAppInNewTab = () => w.postMessage("open-app-in-new-tab", "*")
const openLinkInBackgroundTab = (url: string) => { const openLinkInBackgroundTab = (url: string) => {
if (isBrowserExtensionInstalled) { if (isBrowserExtensionInstalled) {
w.postMessage(`open-link-in-background-tab:${url}`, "*") w.postMessage(`open-link-in-background-tab:${url}`, "*")
} else { } else {
// fallback to ctrl+click simulation // fallback to ctrl+click simulation
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
a.rel = "noreferrer" a.rel = "noreferrer"
a.dispatchEvent( a.dispatchEvent(
new MouseEvent("click", { new MouseEvent("click", {
ctrlKey: true, ctrlKey: true,
metaKey: true, metaKey: true,
}) })
) )
} }
} }
const setBadgeUnreadCount = (count: number | string) => w.postMessage(`set-badge-unread-count:${count}`, "*") const setBadgeUnreadCount = (count: number | string) => w.postMessage(`set-badge-unread-count:${count}`, "*")
return { return {
browserExtensionVersion, browserExtensionVersion,
isBrowserExtensionInstallable, isBrowserExtensionInstallable,
isBrowserExtensionInstalled, isBrowserExtensionInstalled,
isBrowserExtensionPopup, isBrowserExtensionPopup,
openSettingsPage, openSettingsPage,
openAppInNewTab, openAppInNewTab,
openLinkInBackgroundTab, openLinkInBackgroundTab,
setBadgeUnreadCount, setBadgeUnreadCount,
} }
} }

View File

@@ -1,20 +1,20 @@
// the color scheme to use to render components // the color scheme to use to render components
import { useMantineColorScheme } from "@mantine/core" import { useMantineColorScheme } from "@mantine/core"
import { useMediaQuery } from "@mantine/hooks" import { useMediaQuery } from "@mantine/hooks"
export const useColorScheme = () => { export const useColorScheme = () => {
const systemColorScheme = useMediaQuery( const systemColorScheme = useMediaQuery(
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
// passing undefined will use window.matchMedia(query) as default value // passing undefined will use window.matchMedia(query) as default value
undefined, undefined,
{ {
// get initial value synchronously and not in useEffect to avoid flash of light theme // get initial value synchronously and not in useEffect to avoid flash of light theme
getInitialValueInEffect: false, getInitialValueInEffect: false,
} }
) )
? "dark" ? "dark"
: "light" : "light"
const { colorScheme } = useMantineColorScheme() const { colorScheme } = useMantineColorScheme()
return colorScheme === "auto" ? systemColorScheme : colorScheme return colorScheme === "auto" ? systemColorScheme : colorScheme
} }

View File

@@ -1,9 +1,9 @@
import { useMediaQuery } from "@mantine/hooks" import { useMediaQuery } from "@mantine/hooks"
import { Constants } from "app/constants" import { Constants } from "app/constants"
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => { export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
return !useMediaQuery(`(min-width: ${bp})`, undefined, { return !useMediaQuery(`(min-width: ${bp})`, undefined, {
getInitialValueInEffect: false, getInitialValueInEffect: false,
}) })
} }

View File

@@ -1,22 +1,22 @@
import mousetrap, { type ExtendedKeyboardEvent } from "mousetrap" import mousetrap, { type ExtendedKeyboardEvent } from "mousetrap"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
type Callback = (e: ExtendedKeyboardEvent, combo: string) => void type Callback = (e: ExtendedKeyboardEvent, combo: string) => void
export const useMousetrap = (key: string | string[], callback: Callback) => { export const useMousetrap = (key: string | string[], callback: Callback) => {
// use a ref to avoid unbinding/rebinding every time the callback changes // use a ref to avoid unbinding/rebinding every time the callback changes
const callbackRef = useRef(callback) const callbackRef = useRef(callback)
callbackRef.current = callback callbackRef.current = callback
useEffect(() => { useEffect(() => {
mousetrap.bind(key, (event, combo) => { mousetrap.bind(key, (event, combo) => {
callbackRef.current(event, combo) callbackRef.current(event, combo)
// prevent default behavior // prevent default behavior
return false return false
}) })
return () => { return () => {
mousetrap.unbind(key) mousetrap.unbind(key)
} }
}, [key]) }, [key])
} }

View File

@@ -1,7 +1,7 @@
import { type ViewMode } from "app/types" import type { ViewMode } from "app/types"
import useLocalStorage from "use-local-storage" import useLocalStorage from "use-local-storage"
export function useViewMode() { export function useViewMode() {
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("view-mode", "detailed") const [viewMode, setViewMode] = useLocalStorage<ViewMode>("view-mode", "detailed")
return { viewMode, setViewMode } return { viewMode, setViewMode }
} }

View File

@@ -1,49 +1,49 @@
import { setWebSocketConnected } from "app/server/slice" import { setWebSocketConnected } from "app/server/slice"
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store" import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
import { incrementUnreadCount } from "app/tree/slice" import { incrementUnreadCount } from "app/tree/slice"
import { useEffect } from "react" import { useEffect } from "react"
import WebsocketHeartbeatJs from "websocket-heartbeat-js" import WebsocketHeartbeatJs from "websocket-heartbeat-js"
const handleMessage = (dispatch: AppDispatch, message: string) => { const handleMessage = (dispatch: AppDispatch, message: string) => {
const parts = message.split(":") const parts = message.split(":")
const type = parts[0] const type = parts[0]
if (type === "new-feed-entries") { if (type === "new-feed-entries") {
dispatch( dispatch(
incrementUnreadCount({ incrementUnreadCount({
feedId: +parts[1], feedId: +parts[1],
amount: +parts[2], amount: +parts[2],
}) })
) )
} }
} }
export const useWebSocket = () => { export const useWebSocket = () => {
const websocketEnabled = useAppSelector(state => state.server.serverInfos?.websocketEnabled) const websocketEnabled = useAppSelector(state => state.server.serverInfos?.websocketEnabled)
const websocketPingInterval = useAppSelector(state => state.server.serverInfos?.websocketPingInterval) const websocketPingInterval = useAppSelector(state => state.server.serverInfos?.websocketPingInterval)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
useEffect(() => { useEffect(() => {
let ws: WebsocketHeartbeatJs | undefined let ws: WebsocketHeartbeatJs | undefined
if (websocketEnabled && websocketPingInterval) { if (websocketEnabled && websocketPingInterval) {
const currentUrl = new URL(window.location.href) const currentUrl = new URL(window.location.href)
const wsProtocol = currentUrl.protocol === "http:" ? "ws" : "wss" const wsProtocol = currentUrl.protocol === "http:" ? "ws" : "wss"
const wsUrl = `${wsProtocol}://${currentUrl.hostname}:${currentUrl.port}${currentUrl.pathname}ws` const wsUrl = `${wsProtocol}://${currentUrl.hostname}:${currentUrl.port}${currentUrl.pathname}ws`
ws = new WebsocketHeartbeatJs({ ws = new WebsocketHeartbeatJs({
url: wsUrl, url: wsUrl,
pingMsg: "ping", pingMsg: "ping",
pingTimeout: websocketPingInterval, pingTimeout: websocketPingInterval,
}) })
ws.onopen = () => dispatch(setWebSocketConnected(true)) ws.onopen = () => dispatch(setWebSocketConnected(true))
ws.onclose = () => dispatch(setWebSocketConnected(false)) ws.onclose = () => dispatch(setWebSocketConnected(false))
ws.onmessage = event => { ws.onmessage = event => {
if (typeof event.data === "string") { if (typeof event.data === "string") {
handleMessage(dispatch, event.data) handleMessage(dispatch, event.data)
} }
} }
} }
return () => ws?.close() return () => ws?.close()
}, [dispatch, websocketEnabled, websocketPingInterval]) }, [dispatch, websocketEnabled, websocketPingInterval])
} }

View File

@@ -1,64 +1,64 @@
import { i18n, type Messages } from "@lingui/core" import { type Messages, i18n } from "@lingui/core"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import dayjs from "dayjs" import dayjs from "dayjs"
import { useEffect } from "react" import { useEffect } from "react"
interface Locale { interface Locale {
key: string key: string
label: string label: string
dayjsImportFn: () => Promise<ILocale> dayjsImportFn: () => Promise<ILocale>
} }
// add an object to the array to add a new locale // add an object to the array to add a new locale
// don't forget to also add it to the 'locales' array in .linguirc // don't forget to also add it to the 'locales' array in .linguirc
export const locales: Locale[] = [ export const locales: Locale[] = [
{ key: "ar", label: "العربية", dayjsImportFn: async () => await import("dayjs/locale/ar") }, { key: "ar", label: "العربية", dayjsImportFn: async () => await import("dayjs/locale/ar") },
{ key: "ca", label: "Català", dayjsImportFn: async () => await import("dayjs/locale/ca") }, { key: "ca", label: "Català", dayjsImportFn: async () => await import("dayjs/locale/ca") },
{ key: "cs", label: "Čeština", dayjsImportFn: async () => await import("dayjs/locale/cs") }, { key: "cs", label: "Čeština", dayjsImportFn: async () => await import("dayjs/locale/cs") },
{ key: "cy", label: "Cymraeg", dayjsImportFn: async () => await import("dayjs/locale/cy") }, { key: "cy", label: "Cymraeg", dayjsImportFn: async () => await import("dayjs/locale/cy") },
{ key: "da", label: "Danish", dayjsImportFn: async () => await import("dayjs/locale/da") }, { key: "da", label: "Danish", dayjsImportFn: async () => await import("dayjs/locale/da") },
{ key: "de", label: "Deutsch", dayjsImportFn: async () => await import("dayjs/locale/de") }, { key: "de", label: "Deutsch", dayjsImportFn: async () => await import("dayjs/locale/de") },
{ key: "en", label: "English", dayjsImportFn: async () => await import("dayjs/locale/en") }, { key: "en", label: "English", dayjsImportFn: async () => await import("dayjs/locale/en") },
{ key: "es", label: "Español", dayjsImportFn: async () => await import("dayjs/locale/es") }, { key: "es", label: "Español", dayjsImportFn: async () => await import("dayjs/locale/es") },
{ key: "fa", label: "فارسی", dayjsImportFn: async () => await import("dayjs/locale/fa") }, { key: "fa", label: "فارسی", dayjsImportFn: async () => await import("dayjs/locale/fa") },
{ key: "fi", label: "Suomi", dayjsImportFn: async () => await import("dayjs/locale/fi") }, { key: "fi", label: "Suomi", dayjsImportFn: async () => await import("dayjs/locale/fi") },
{ key: "fr", label: "Français", dayjsImportFn: async () => await import("dayjs/locale/fr") }, { key: "fr", label: "Français", dayjsImportFn: async () => await import("dayjs/locale/fr") },
{ key: "gl", label: "Galician", dayjsImportFn: async () => await import("dayjs/locale/gl") }, { key: "gl", label: "Galician", dayjsImportFn: async () => await import("dayjs/locale/gl") },
{ key: "hu", label: "Magyar", dayjsImportFn: async () => await import("dayjs/locale/hu") }, { key: "hu", label: "Magyar", dayjsImportFn: async () => await import("dayjs/locale/hu") },
{ key: "id", label: "Indonesian", dayjsImportFn: async () => await import("dayjs/locale/id") }, { key: "id", label: "Indonesian", dayjsImportFn: async () => await import("dayjs/locale/id") },
{ key: "it", label: "Italiano", dayjsImportFn: async () => await import("dayjs/locale/it") }, { key: "it", label: "Italiano", dayjsImportFn: async () => await import("dayjs/locale/it") },
{ key: "ja", label: "日本語", dayjsImportFn: async () => await import("dayjs/locale/ja") }, { key: "ja", label: "日本語", dayjsImportFn: async () => await import("dayjs/locale/ja") },
{ key: "ko", label: "한국어", dayjsImportFn: async () => await import("dayjs/locale/ko") }, { key: "ko", label: "한국어", dayjsImportFn: async () => await import("dayjs/locale/ko") },
{ key: "ms", label: "Bahasa Malaysian", dayjsImportFn: async () => await import("dayjs/locale/ms") }, { key: "ms", label: "Bahasa Malaysian", dayjsImportFn: async () => await import("dayjs/locale/ms") },
{ key: "nb", label: "Norsk (bokmål)", dayjsImportFn: async () => await import("dayjs/locale/nb") }, { key: "nb", label: "Norsk (bokmål)", dayjsImportFn: async () => await import("dayjs/locale/nb") },
{ key: "nl", label: "Nederlands", dayjsImportFn: async () => await import("dayjs/locale/nl") }, { key: "nl", label: "Nederlands", dayjsImportFn: async () => await import("dayjs/locale/nl") },
{ key: "nn", label: "Norsk (nynorsk)", dayjsImportFn: async () => await import("dayjs/locale/nn") }, { key: "nn", label: "Norsk (nynorsk)", dayjsImportFn: async () => await import("dayjs/locale/nn") },
{ key: "pl", label: "Polski", dayjsImportFn: async () => await import("dayjs/locale/pl") }, { key: "pl", label: "Polski", dayjsImportFn: async () => await import("dayjs/locale/pl") },
{ key: "pt", label: "Português", dayjsImportFn: async () => await import("dayjs/locale/pt") }, { key: "pt", label: "Português", dayjsImportFn: async () => await import("dayjs/locale/pt") },
{ key: "ru", label: "Русский", dayjsImportFn: async () => await import("dayjs/locale/ru") }, { key: "ru", label: "Русский", dayjsImportFn: async () => await import("dayjs/locale/ru") },
{ key: "sk", label: "Slovenčina", dayjsImportFn: async () => await import("dayjs/locale/sk") }, { key: "sk", label: "Slovenčina", dayjsImportFn: async () => await import("dayjs/locale/sk") },
{ key: "sv", label: "Svenska", dayjsImportFn: async () => await import("dayjs/locale/sv") }, { key: "sv", label: "Svenska", dayjsImportFn: async () => await import("dayjs/locale/sv") },
{ key: "tr", label: "Türkçe", dayjsImportFn: async () => await import("dayjs/locale/tr") }, { key: "tr", label: "Türkçe", dayjsImportFn: async () => await import("dayjs/locale/tr") },
{ key: "zh", label: "简体中文", dayjsImportFn: async () => await import("dayjs/locale/zh") }, { key: "zh", label: "简体中文", dayjsImportFn: async () => await import("dayjs/locale/zh") },
] ]
function activateLocale(locale: string) { function activateLocale(locale: string) {
// lingui // lingui
import(`./locales/${locale}/messages.po`).then((data: { messages: Messages }) => { import(`./locales/${locale}/messages.po`).then((data: { messages: Messages }) => {
i18n.load(locale, data.messages) i18n.load(locale, data.messages)
i18n.activate(locale) i18n.activate(locale)
}) })
// dayjs // dayjs
locales locales
.find(l => l.key === locale) .find(l => l.key === locale)
?.dayjsImportFn() ?.dayjsImportFn()
.then(() => dayjs.locale(locale)) .then(() => dayjs.locale(locale))
} }
export const useI18n = () => { export const useI18n = () => {
const locale = useAppSelector(state => state.user.settings?.language) const locale = useAppSelector(state => state.user.settings?.language)
useEffect(() => { useEffect(() => {
activateLocale(locale ?? "en") activateLocale(locale ?? "en")
}, [locale]) }, [locale])
} }

View File

@@ -1,59 +1,59 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Button, Container, Group, Text, Title } from "@mantine/core" import { Box, Button, Container, Group, Text, Title } from "@mantine/core"
import { TbRefresh } from "react-icons/tb" import { TbRefresh } from "react-icons/tb"
import { tss } from "tss" import { tss } from "tss"
import { PageTitle } from "./PageTitle" import { PageTitle } from "./PageTitle"
const useStyles = tss.create(({ theme }) => ({ const useStyles = tss.create(({ theme }) => ({
root: { root: {
paddingTop: 80, paddingTop: 80,
}, },
label: { label: {
textAlign: "center", textAlign: "center",
fontWeight: "bold", fontWeight: "bold",
fontSize: 120, fontSize: 120,
lineHeight: 1, lineHeight: 1,
marginBottom: `calc(${theme.spacing.xl} * 1.5)`, marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
color: theme.colors[theme.primaryColor][3], color: theme.colors[theme.primaryColor][3],
}, },
title: { title: {
textAlign: "center", textAlign: "center",
fontWeight: "bold", fontWeight: "bold",
fontSize: 32, fontSize: 32,
}, },
description: { description: {
maxWidth: 540, maxWidth: 540,
margin: "auto", margin: "auto",
marginTop: theme.spacing.xl, marginTop: theme.spacing.xl,
marginBottom: `calc(${theme.spacing.xl} * 1.5)`, marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
}, },
})) }))
export function ErrorPage(props: { error: Error }) { export function ErrorPage(props: { error: Error }) {
const { classes } = useStyles() const { classes } = useStyles()
return ( return (
<div className={classes.root}> <div className={classes.root}>
<Container> <Container>
<PageTitle /> <PageTitle />
<Box className={classes.label}> <Box className={classes.label}>
<Trans>Oops!</Trans> <Trans>Oops!</Trans>
</Box> </Box>
<Title className={classes.title}> <Title className={classes.title}>
<Trans>Something bad just happened...</Trans> <Trans>Something bad just happened...</Trans>
</Title> </Title>
<Text size="lg" ta="center" className={classes.description}> <Text size="lg" ta="center" className={classes.description}>
{props.error.message} {props.error.message}
</Text> </Text>
<Group justify="center"> <Group justify="center">
<Button size="md" onClick={() => window.location.reload()} leftSection={<TbRefresh size={18} />}> <Button size="md" onClick={() => window.location.reload()} leftSection={<TbRefresh size={18} />}>
Refresh the page Refresh the page
</Button> </Button>
</Group> </Group>
</Container> </Container>
</div> </div>
) )
} }

View File

@@ -1,27 +1,27 @@
import { Center, Container, RingProgress, Text, useMantineTheme } from "@mantine/core" import { Center, Container, RingProgress, Text, useMantineTheme } from "@mantine/core"
import { useAppLoading } from "hooks/useAppLoading" import { useAppLoading } from "hooks/useAppLoading"
import { PageTitle } from "./PageTitle" import { PageTitle } from "./PageTitle"
export function LoadingPage() { export function LoadingPage() {
const theme = useMantineTheme() const theme = useMantineTheme()
const { loadingPercentage, loadingStepLabel } = useAppLoading() const { loadingPercentage, loadingStepLabel } = useAppLoading()
return ( return (
<Container size="xs"> <Container size="xs">
<PageTitle /> <PageTitle />
<Center> <Center>
<RingProgress <RingProgress
sections={[{ value: loadingPercentage, color: theme.primaryColor }]} sections={[{ value: loadingPercentage, color: theme.primaryColor }]}
label={ label={
<Text fw="bold" ta="center" size="xl"> <Text fw="bold" ta="center" size="xl">
{loadingPercentage}% {loadingPercentage}%
</Text> </Text>
} }
/> />
</Center> </Center>
{loadingStepLabel && <Center>{loadingStepLabel}</Center>} {loadingStepLabel && <Center>{loadingStepLabel}</Center>}
</Container> </Container>
) )
} }

View File

@@ -1,13 +1,13 @@
import { Center, Title } from "@mantine/core" import { Center, Title } from "@mantine/core"
import { Logo } from "components/Logo" import { Logo } from "components/Logo"
export function PageTitle() { export function PageTitle() {
return ( return (
<Center my="xl"> <Center my="xl">
<Logo size={48} /> <Logo size={48} />
<Title order={1} ml="md"> <Title order={1} ml="md">
CommaFeed CommaFeed
</Title> </Title>
</Center> </Center>
) )
} }

View File

@@ -1,154 +1,154 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core" import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
import { client } from "app/client" import { client } from "app/client"
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "app/redirect/thunks" import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import welcomePageDark from "assets/welcome_page_dark.png" import welcomePageDark from "assets/welcome_page_dark.png"
import welcomePageLight from "assets/welcome_page_light.png" import welcomePageLight from "assets/welcome_page_light.png"
import { ActionButton } from "components/ActionButton" import { ActionButton } from "components/ActionButton"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { SiGithub, SiTwitter } from "react-icons/si" import { SiGithub, SiTwitter } from "react-icons/si"
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb" import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
import { PageTitle } from "./PageTitle" import { PageTitle } from "./PageTitle"
const iconSize = 18 const iconSize = 18
export function WelcomePage() { export function WelcomePage() {
const serverInfos = useAppSelector(state => state.server.serverInfos) const serverInfos = useAppSelector(state => state.server.serverInfos)
const { colorScheme } = useMantineColorScheme() const { colorScheme } = useMantineColorScheme()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const image = colorScheme === "light" ? welcomePageLight : welcomePageDark const image = colorScheme === "light" ? welcomePageLight : welcomePageDark
const login = useAsyncCallback(client.user.login, { const login = useAsyncCallback(client.user.login, {
onSuccess: () => { onSuccess: () => {
dispatch(redirectToRootCategory()) dispatch(redirectToRootCategory())
}, },
}) })
return ( return (
<Container> <Container>
<Header /> <Header />
<Center my="lg"> <Center my="lg">
<Title order={3}>Bloat-free feed reader</Title> <Title order={3}>Bloat-free feed reader</Title>
</Center> </Center>
{serverInfos?.demoAccountEnabled && ( {serverInfos?.demoAccountEnabled && (
<Center> <Center>
<ActionButton <ActionButton
label={<Trans>Try the demo!</Trans>} label={<Trans>Try the demo!</Trans>}
icon={<TbClock size={iconSize} />} icon={<TbClock size={iconSize} />}
variant="outline" variant="outline"
onClick={async () => await login.execute({ name: "demo", password: "demo" })} onClick={async () => await login.execute({ name: "demo", password: "demo" })}
showLabelOnMobile showLabelOnMobile
/> />
</Center> </Center>
)} )}
<Divider my="lg" /> <Divider my="lg" />
<Image src={image} /> <Image src={image} />
<Divider my="lg" /> <Divider my="lg" />
<Footer /> <Footer />
<Space h="lg" /> <Space h="lg" />
</Container> </Container>
) )
} }
function Header() { function Header() {
const mobile = useMobile() const mobile = useMobile()
if (mobile) { if (mobile) {
return ( return (
<> <>
<PageTitle /> <PageTitle />
<Center> <Center>
<Buttons /> <Buttons />
</Center> </Center>
</> </>
) )
} }
return ( return (
<Group justify="space-between"> <Group justify="space-between">
<Box> <Box>
<PageTitle /> <PageTitle />
</Box> </Box>
<Box> <Box>
<Buttons /> <Buttons />
</Box> </Box>
</Group> </Group>
) )
} }
function Buttons() { function Buttons() {
const serverInfos = useAppSelector(state => state.server.serverInfos) const serverInfos = useAppSelector(state => state.server.serverInfos)
const { colorScheme, toggleColorScheme } = useMantineColorScheme() const { colorScheme, toggleColorScheme } = useMantineColorScheme()
const { isBrowserExtensionPopup, openSettingsPage } = useBrowserExtension() const { isBrowserExtensionPopup, openSettingsPage } = useBrowserExtension()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const dark = colorScheme === "dark" const dark = colorScheme === "dark"
return ( return (
<Group gap={14}> <Group gap={14}>
<ActionButton <ActionButton
label={<Trans>Log in</Trans>} label={<Trans>Log in</Trans>}
icon={<TbKey size={iconSize} />} icon={<TbKey size={iconSize} />}
variant="outline" variant="outline"
onClick={async () => await dispatch(redirectToLogin())} onClick={async () => await dispatch(redirectToLogin())}
showLabelOnMobile showLabelOnMobile
/> />
{serverInfos?.allowRegistrations && ( {serverInfos?.allowRegistrations && (
<ActionButton <ActionButton
label={<Trans>Sign up</Trans>} label={<Trans>Sign up</Trans>}
icon={<TbUserPlus size={iconSize} />} icon={<TbUserPlus size={iconSize} />}
variant="filled" variant="filled"
onClick={async () => await dispatch(redirectToRegistration())} onClick={async () => await dispatch(redirectToRegistration())}
showLabelOnMobile showLabelOnMobile
/> />
)} )}
<ActionButton <ActionButton
label={dark ? <Trans>Switch to light theme</Trans> : <Trans>Switch to dark theme</Trans>} label={dark ? <Trans>Switch to light theme</Trans> : <Trans>Switch to dark theme</Trans>}
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />} icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
onClick={() => toggleColorScheme()} onClick={() => toggleColorScheme()}
hideLabelOnDesktop hideLabelOnDesktop
/> />
{isBrowserExtensionPopup && ( {isBrowserExtensionPopup && (
<ActionButton <ActionButton
label={<Trans>Extension options</Trans>} label={<Trans>Extension options</Trans>}
icon={<TbSettings size={iconSize} />} icon={<TbSettings size={iconSize} />}
onClick={() => openSettingsPage()} onClick={() => openSettingsPage()}
hideLabelOnDesktop hideLabelOnDesktop
/> />
)} )}
</Group> </Group>
) )
} }
function Footer() { function Footer() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return ( return (
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
<span>© CommaFeed</span> <span>© CommaFeed</span>
<Anchor variant="text" href="https://github.com/Athou/commafeed/" target="_blank" rel="noreferrer"> <Anchor variant="text" href="https://github.com/Athou/commafeed/" target="_blank" rel="noreferrer">
<SiGithub /> <SiGithub />
</Anchor> </Anchor>
<Anchor variant="text" href="https://twitter.com/CommaFeed" target="_blank" rel="noreferrer"> <Anchor variant="text" href="https://twitter.com/CommaFeed" target="_blank" rel="noreferrer">
<SiTwitter /> <SiTwitter />
</Anchor> </Anchor>
</Group> </Group>
<Box> <Box>
<Anchor variant="text" onClick={async () => await dispatch(redirectToApiDocumentation())}> <Anchor variant="text" onClick={async () => await dispatch(redirectToApiDocumentation())}>
API documentation API documentation
</Anchor> </Anchor>
</Box> </Box>
</Group> </Group>
) )
} }

View File

@@ -1,153 +1,153 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { ActionIcon, Box, Code, Container, Group, Table, Text, Title, useMantineTheme } from "@mantine/core" import { ActionIcon, Box, Code, Container, Group, Table, Text, Title, useMantineTheme } from "@mantine/core"
import { closeAllModals, openConfirmModal, openModal } from "@mantine/modals" import { closeAllModals, openConfirmModal, openModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { type UserModel } from "app/types" import type { UserModel } from "app/types"
import { UserEdit } from "components/admin/UserEdit" import { Alert } from "components/Alert"
import { Alert } from "components/Alert" import { Loader } from "components/Loader"
import { Loader } from "components/Loader" import { RelativeDate } from "components/RelativeDate"
import { RelativeDate } from "components/RelativeDate" import { UserEdit } from "components/admin/UserEdit"
import { type ReactNode } from "react" import type { ReactNode } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook" import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbCheck, TbPencil, TbPlus, TbTrash, TbX } from "react-icons/tb" import { TbCheck, TbPencil, TbPlus, TbTrash, TbX } from "react-icons/tb"
function BooleanIcon({ value }: { value: boolean }) { function BooleanIcon({ value }: { value: boolean }) {
return value ? <TbCheck size={18} /> : <TbX size={18} /> return value ? <TbCheck size={18} /> : <TbX size={18} />
} }
export function AdminUsersPage() { export function AdminUsersPage() {
const theme = useMantineTheme() const theme = useMantineTheme()
const query = useAsync(async () => await client.admin.getAllUsers(), []) const query = useAsync(async () => await client.admin.getAllUsers(), [])
const users = query.result?.data.sort((a, b) => a.id - b.id) const users = query.result?.data.sort((a, b) => a.id - b.id)
const deleteUser = useAsyncCallback(client.admin.deleteUser, { const deleteUser = useAsyncCallback(client.admin.deleteUser, {
onSuccess: () => { onSuccess: () => {
query.execute() query.execute()
closeAllModals() closeAllModals()
}, },
}) })
const openUserEditModal = (title: ReactNode, user?: UserModel) => { const openUserEditModal = (title: ReactNode, user?: UserModel) => {
openModal({ openModal({
title, title,
children: ( children: (
<UserEdit <UserEdit
user={user} user={user}
onCancel={closeAllModals} onCancel={closeAllModals}
onSave={() => { onSave={() => {
query.execute() query.execute()
closeAllModals() closeAllModals()
}} }}
/> />
), ),
}) })
} }
const openUserDeleteModal = (user: UserModel) => { const openUserDeleteModal = (user: UserModel) => {
const userName = user.name const userName = user.name
openConfirmModal({ openConfirmModal({
title: <Trans>Delete user</Trans>, title: <Trans>Delete user</Trans>,
children: ( children: (
<Text size="sm"> <Text size="sm">
<Trans> <Trans>
Are you sure you want to delete user <Code>{userName}</Code> ? Are you sure you want to delete user <Code>{userName}</Code> ?
</Trans> </Trans>
</Text> </Text>
), ),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> }, labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm: async () => await deleteUser.execute({ id: user.id }), onConfirm: async () => await deleteUser.execute({ id: user.id }),
}) })
} }
if (!users) return <Loader /> if (!users) return <Loader />
return ( return (
<Container> <Container>
<Title order={3} mb="md"> <Title order={3} mb="md">
<Group> <Group>
<Trans>Manage users</Trans> <Trans>Manage users</Trans>
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}> <ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
<TbPlus size={20} /> <TbPlus size={20} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Title> </Title>
{deleteUser.error && ( {deleteUser.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(deleteUser.error)} /> <Alert messages={errorToStrings(deleteUser.error)} />
</Box> </Box>
)} )}
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th> <Table.Th>
<Trans>Id</Trans> <Trans>Id</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>Name</Trans> <Trans>Name</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>E-mail</Trans> <Trans>E-mail</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>Date created</Trans> <Trans>Date created</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>Last login date</Trans> <Trans>Last login date</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>Admin</Trans> <Trans>Admin</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>Enabled</Trans> <Trans>Enabled</Trans>
</Table.Th> </Table.Th>
<Table.Th> <Table.Th>
<Trans>Actions</Trans> <Trans>Actions</Trans>
</Table.Th> </Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{users.map(u => ( {users.map(u => (
<Table.Tr key={u.id}> <Table.Tr key={u.id}>
<Table.Td>{u.id}</Table.Td> <Table.Td>{u.id}</Table.Td>
<Table.Td>{u.name}</Table.Td> <Table.Td>{u.name}</Table.Td>
<Table.Td>{u.email}</Table.Td> <Table.Td>{u.email}</Table.Td>
<Table.Td> <Table.Td>
<RelativeDate date={u.created} /> <RelativeDate date={u.created} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RelativeDate date={u.lastLogin} /> <RelativeDate date={u.lastLogin} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<BooleanIcon value={u.admin} /> <BooleanIcon value={u.admin} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<BooleanIcon value={u.enabled} /> <BooleanIcon value={u.enabled} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group> <Group>
<ActionIcon <ActionIcon
color={theme.primaryColor} color={theme.primaryColor}
variant="subtle" variant="subtle"
onClick={() => openUserEditModal(<Trans>Edit user</Trans>, u)} onClick={() => openUserEditModal(<Trans>Edit user</Trans>, u)}
> >
<TbPencil size={18} /> <TbPencil size={18} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
color={theme.primaryColor} color={theme.primaryColor}
variant="subtle" variant="subtle"
onClick={() => openUserDeleteModal(u)} onClick={() => openUserDeleteModal(u)}
loading={deleteUser.loading} loading={deleteUser.loading}
> >
<TbTrash size={18} /> <TbTrash size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Container> </Container>
) )
} }

View File

@@ -1,74 +1,74 @@
import { Accordion, Box, Tabs } from "@mantine/core" import { Accordion, Box, Tabs } from "@mantine/core"
import { client } from "app/client" import { client } from "app/client"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { Gauge } from "components/metrics/Gauge" import { Gauge } from "components/metrics/Gauge"
import { Meter } from "components/metrics/Meter" import { Meter } from "components/metrics/Meter"
import { MetricAccordionItem } from "components/metrics/MetricAccordionItem" import { MetricAccordionItem } from "components/metrics/MetricAccordionItem"
import { Timer } from "components/metrics/Timer" import { Timer } from "components/metrics/Timer"
import { useAsync } from "react-async-hook" import { useAsync } from "react-async-hook"
import { TbChartAreaLine, TbClock } from "react-icons/tb" import { TbChartAreaLine, TbClock } from "react-icons/tb"
const shownMeters: Record<string, string> = { const shownMeters: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.refill": "Feed queue refill rate", "com.commafeed.backend.feed.FeedRefreshEngine.refill": "Feed queue refill rate",
"com.commafeed.backend.feed.FeedRefreshWorker.feedFetched": "Feed fetching rate", "com.commafeed.backend.feed.FeedRefreshWorker.feedFetched": "Feed fetching rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.feedUpdated": "Feed update rate", "com.commafeed.backend.feed.FeedRefreshUpdater.feedUpdated": "Feed update rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheHit": "Entry cache hit rate", "com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheHit": "Entry cache hit rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheMiss": "Entry cache miss rate", "com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheMiss": "Entry cache miss rate",
"com.commafeed.backend.service.db.DatabaseCleaningService.entriesDeleted": "Entries deleted", "com.commafeed.backend.service.db.DatabaseCleaningService.entriesDeleted": "Entries deleted",
} }
const shownGauges: Record<string, string> = { const shownGauges: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Queue size", "com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Queue size",
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Worker active", "com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Worker active",
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Updater active", "com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Updater active",
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users", "com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
"com.commafeed.frontend.ws.WebSocketSessions.sessions": "WebSocket sessions", "com.commafeed.frontend.ws.WebSocketSessions.sessions": "WebSocket sessions",
} }
export function MetricsPage() { export function MetricsPage() {
const query = useAsync(async () => await client.admin.getMetrics(), []) const query = useAsync(async () => await client.admin.getMetrics(), [])
if (!query.result) return <Loader /> if (!query.result) return <Loader />
const { meters, gauges, timers } = query.result.data const { meters, gauges, timers } = query.result.data
return ( return (
<Tabs defaultValue="stats"> <Tabs defaultValue="stats">
<Tabs.List> <Tabs.List>
<Tabs.Tab value="stats" leftSection={<TbChartAreaLine size={14} />}> <Tabs.Tab value="stats" leftSection={<TbChartAreaLine size={14} />}>
Stats Stats
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="timers" leftSection={<TbClock size={14} />}> <Tabs.Tab value="timers" leftSection={<TbClock size={14} />}>
Timers Timers
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="stats" pt="xs"> <Tabs.Panel value="stats" pt="xs">
<Accordion variant="contained" chevronPosition="left"> <Accordion variant="contained" chevronPosition="left">
{Object.keys(shownMeters).map(m => ( {Object.keys(shownMeters).map(m => (
<MetricAccordionItem key={m} metricKey={m} name={shownMeters[m]} headerValue={meters[m].count}> <MetricAccordionItem key={m} metricKey={m} name={shownMeters[m]} headerValue={meters[m].count}>
<Meter meter={meters[m]} /> <Meter meter={meters[m]} />
</MetricAccordionItem> </MetricAccordionItem>
))} ))}
</Accordion> </Accordion>
<Box pt="xs"> <Box pt="xs">
{Object.keys(shownGauges).map(g => ( {Object.keys(shownGauges).map(g => (
<Box key={g}> <Box key={g}>
<span>{shownGauges[g]}&nbsp;</span> <span>{shownGauges[g]}&nbsp;</span>
<Gauge gauge={gauges[g]} /> <Gauge gauge={gauges[g]} />
</Box> </Box>
))} ))}
</Box> </Box>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="timers" pt="xs"> <Tabs.Panel value="timers" pt="xs">
<Accordion variant="contained" chevronPosition="left"> <Accordion variant="contained" chevronPosition="left">
{Object.keys(timers).map(key => ( {Object.keys(timers).map(key => (
<MetricAccordionItem key={key} metricKey={key} name={key} headerValue={timers[key].count}> <MetricAccordionItem key={key} metricKey={key} name={key} headerValue={timers[key].count}>
<Timer timer={timers[key]} /> <Timer timer={timers[key]} />
</MetricAccordionItem> </MetricAccordionItem>
))} ))}
</Accordion> </Accordion>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
) )
} }

View File

@@ -1,129 +1,130 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Container, List, NativeSelect, SimpleGrid, Title } from "@mantine/core" import { Anchor, Box, Container, List, NativeSelect, SimpleGrid, Title } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectToApiDocumentation } from "app/redirect/thunks" import { redirectToApiDocumentation } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { CategorySelect } from "components/content/add/CategorySelect" import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp" import { CategorySelect } from "components/content/add/CategorySelect"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import React, { useState } from "react" import type React from "react"
import { TbHelp, TbKeyboard, TbPuzzle, TbRocket } from "react-icons/tb" import { useState } from "react"
import { tss } from "tss" import { TbHelp, TbKeyboard, TbPuzzle, TbRocket } from "react-icons/tb"
import { tss } from "tss"
const useStyles = tss.create(() => ({
sectionTitle: { const useStyles = tss.create(() => ({
display: "flex", sectionTitle: {
alignItems: "center", display: "flex",
}, alignItems: "center",
})) },
}))
function Section(props: { title: React.ReactNode; icon: React.ReactNode; children: React.ReactNode }) {
const { classes } = useStyles() function Section(props: { title: React.ReactNode; icon: React.ReactNode; children: React.ReactNode }) {
return ( const { classes } = useStyles()
<Box my="xl"> return (
<Box className={classes.sectionTitle} mb="xs"> <Box my="xl">
{props.icon} <Box className={classes.sectionTitle} mb="xs">
<Title order={3} ml="xs"> {props.icon}
{props.title} <Title order={3} ml="xs">
</Title> {props.title}
</Box> </Title>
<Box>{props.children}</Box> </Box>
</Box> <Box>{props.children}</Box>
) </Box>
} )
}
function NextUnreadBookmarklet() {
const [categoryId, setCategoryId] = useState(Constants.categories.all.id) function NextUnreadBookmarklet() {
const [order, setOrder] = useState("desc") const [categoryId, setCategoryId] = useState(Constants.categories.all.id)
const baseUrl = window.location.href.substring(0, window.location.href.lastIndexOf("#")) const [order, setOrder] = useState("desc")
const href = `javascript:window.location.href='${baseUrl}next?category=${categoryId}&order=${order}&t='+new Date().getTime();` const baseUrl = window.location.href.substring(0, window.location.href.lastIndexOf("#"))
const href = `javascript:window.location.href='${baseUrl}next?category=${categoryId}&order=${order}&t='+new Date().getTime();`
return (
<Box> return (
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} /> <Box>
<NativeSelect <CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
data={[ <NativeSelect
{ value: "desc", label: t`Newest first` }, data={[
{ value: "asc", label: t`Oldest first` }, { value: "desc", label: t`Newest first` },
]} { value: "asc", label: t`Oldest first` },
value={order} ]}
onChange={e => setOrder(e.target.value)} value={order}
description={<Trans>Order</Trans>} onChange={e => setOrder(e.target.value)}
/> description={<Trans>Order</Trans>}
<Trans>Drag link to bookmark bar</Trans> />
<span> </span> <Trans>Drag link to bookmark bar</Trans>
<Anchor href={href} target="_blank" rel="noreferrer"> <span> </span>
<Trans>CommaFeed next unread item</Trans> <Anchor href={href} target="_blank" rel="noreferrer">
</Anchor> <Trans>CommaFeed next unread item</Trans>
</Box> </Anchor>
) </Box>
} )
}
export function AboutPage() {
const version = useAppSelector(state => state.server.serverInfos?.version) export function AboutPage() {
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit) const version = useAppSelector(state => state.server.serverInfos?.version)
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension() const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
const dispatch = useAppDispatch() const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
const dispatch = useAppDispatch()
return (
<Container size="xl"> return (
<SimpleGrid cols={{ base: 1, [Constants.layout.mobileBreakpointName]: 2 }}> <Container size="xl">
<Section title={<Trans>About</Trans>} icon={<TbHelp size={24} />}> <SimpleGrid cols={{ base: 1, [Constants.layout.mobileBreakpointName]: 2 }}>
<Box> <Section title={<Trans>About</Trans>} icon={<TbHelp size={24} />}>
<Trans> <Box>
CommaFeed version {version} ({revision}). <Trans>
</Trans> CommaFeed version {version} ({revision}).
</Box> </Trans>
{isBrowserExtensionInstallable && isBrowserExtensionInstalled && ( </Box>
<Box> {isBrowserExtensionInstallable && isBrowserExtensionInstalled && (
<Trans>CommaFeed browser extension version {browserExtensionVersion}.</Trans> <Box>
</Box> <Trans>CommaFeed browser extension version {browserExtensionVersion}.</Trans>
)} </Box>
<Box mt="md"> )}
<Trans> <Box mt="md">
<span>CommaFeed is an open-source project. Sources are hosted on </span> <Trans>
<Anchor href="https://github.com/Athou/commafeed" target="_blank" rel="noreferrer"> <span>CommaFeed is an open-source project. Sources are hosted on </span>
GitHub <Anchor href="https://github.com/Athou/commafeed" target="_blank" rel="noreferrer">
</Anchor> GitHub
. </Anchor>
</Trans> .
</Box> </Trans>
<Box> </Box>
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans> <Box>
</Box> <Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
</Section> </Box>
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}> </Section>
<List> <Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
<List.Item> <List>
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer"> <List.Item>
<Trans>Browser extention</Trans> <Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
</Anchor> <Trans>Browser extention</Trans>
</List.Item> </Anchor>
<List.Item> </List.Item>
<Trans>Subscribe URL</Trans> <List.Item>
<span> </span> <Trans>Subscribe URL</Trans>
<Anchor href="rest/feed/subscribe?url=FEED_URL_HERE" target="_blank" rel="noreferrer"> <span> </span>
rest/feed/subscribe?url=FEED_URL_HERE <Anchor href="rest/feed/subscribe?url=FEED_URL_HERE" target="_blank" rel="noreferrer">
</Anchor> rest/feed/subscribe?url=FEED_URL_HERE
</List.Item> </Anchor>
<List.Item> </List.Item>
<Trans>Next unread item bookmarklet</Trans> <List.Item>
<span> </span> <Trans>Next unread item bookmarklet</Trans>
<Box ml="xl"> <span> </span>
<NextUnreadBookmarklet /> <Box ml="xl">
</Box> <NextUnreadBookmarklet />
</List.Item> </Box>
</List> </List.Item>
</Section> </List>
<Section title={<Trans>Keyboard shortcuts</Trans>} icon={<TbKeyboard size={24} />}> </Section>
<KeyboardShortcutsHelp /> <Section title={<Trans>Keyboard shortcuts</Trans>} icon={<TbKeyboard size={24} />}>
</Section> <KeyboardShortcutsHelp />
<Section title={<Trans>REST API</Trans>} icon={<TbRocket size={24} />}> </Section>
<Anchor onClick={async () => await dispatch(redirectToApiDocumentation())}> <Section title={<Trans>REST API</Trans>} icon={<TbRocket size={24} />}>
<Trans>Go to the API documentation.</Trans> <Anchor onClick={async () => await dispatch(redirectToApiDocumentation())}>
</Anchor> <Trans>Go to the API documentation.</Trans>
</Section> </Anchor>
</SimpleGrid> </Section>
</Container> </SimpleGrid>
) </Container>
} )
}

View File

@@ -1,38 +1,38 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Container, Tabs } from "@mantine/core" import { Container, Tabs } from "@mantine/core"
import { AddCategory } from "components/content/add/AddCategory" import { AddCategory } from "components/content/add/AddCategory"
import { ImportOpml } from "components/content/add/ImportOpml" import { ImportOpml } from "components/content/add/ImportOpml"
import { Subscribe } from "components/content/add/Subscribe" import { Subscribe } from "components/content/add/Subscribe"
import { TbFileImport, TbFolderPlus, TbRss } from "react-icons/tb" import { TbFileImport, TbFolderPlus, TbRss } from "react-icons/tb"
export function AddPage() { export function AddPage() {
return ( return (
<Container size="sm" px={0}> <Container size="sm" px={0}>
<Tabs defaultValue="subscribe"> <Tabs defaultValue="subscribe">
<Tabs.List> <Tabs.List>
<Tabs.Tab value="subscribe" leftSection={<TbRss size={16} />}> <Tabs.Tab value="subscribe" leftSection={<TbRss size={16} />}>
<Trans>Subscribe</Trans> <Trans>Subscribe</Trans>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="category" leftSection={<TbFolderPlus size={16} />}> <Tabs.Tab value="category" leftSection={<TbFolderPlus size={16} />}>
<Trans>Add category</Trans> <Trans>Add category</Trans>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="opml" leftSection={<TbFileImport size={16} />}> <Tabs.Tab value="opml" leftSection={<TbFileImport size={16} />}>
<Trans>OPML</Trans> <Trans>OPML</Trans>
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="subscribe" pt="xl"> <Tabs.Panel value="subscribe" pt="xl">
<Subscribe /> <Subscribe />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="category" pt="xl"> <Tabs.Panel value="category" pt="xl">
<AddCategory /> <AddCategory />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="opml" pt="xl"> <Tabs.Panel value="opml" pt="xl">
<ImportOpml /> <ImportOpml />
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
</Container> </Container>
) )
} }

View File

@@ -1,20 +1,20 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { HistoryService, RedocStandalone } from "redoc" import { HistoryService, RedocStandalone } from "redoc"
// disable redoc url sync because it causes issues with hashrouter // disable redoc url sync because it causes issues with hashrouter
Object.defineProperty(HistoryService.prototype, "replace", { Object.defineProperty(HistoryService.prototype, "replace", {
value: () => { value: () => {
// do nothing // do nothing
}, },
}) })
function ApiDocumentationPage() { function ApiDocumentationPage() {
return ( return (
// force white background because documentation does not support dark theme // force white background because documentation does not support dark theme
<Box style={{ backgroundColor: "#fff" }}> <Box style={{ backgroundColor: "#fff" }}>
<RedocStandalone specUrl="openapi.json" /> <RedocStandalone specUrl="openapi.json" />
</Box> </Box>
) )
} }
export default ApiDocumentationPage export default ApiDocumentationPage

View File

@@ -1,149 +1,149 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core" import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals" import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks" import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { type CategoryModificationRequest } from "app/types" import type { CategoryModificationRequest } from "app/types"
import { flattenCategoryTree } from "app/utils" import { flattenCategoryTree } from "app/utils"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { CategorySelect } from "components/content/add/CategorySelect" import { Loader } from "components/Loader"
import { Loader } from "components/Loader" import { CategorySelect } from "components/content/add/CategorySelect"
import { useEffect } from "react" import { useEffect } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook" import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb" import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
export function CategoryDetailsPage() { export function CategoryDetailsPage() {
const { id = Constants.categories.all.id } = useParams() const { id = Constants.categories.all.id } = useParams()
const apiKey = useAppSelector(state => state.user.profile?.apiKey) const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const query = useAsync(async () => await client.category.getRoot(), []) const query = useAsync(async () => await client.category.getRoot(), [])
const category = const category =
id === Constants.categories.starred.id id === Constants.categories.starred.id
? Constants.categories.starred ? Constants.categories.starred
: query.result && flattenCategoryTree(query.result.data).find(c => c.id === id) : query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
const form = useForm<CategoryModificationRequest>() const form = useForm<CategoryModificationRequest>()
const { setValues } = form const { setValues } = form
const modifyCategory = useAsyncCallback(client.category.modify, { const modifyCategory = useAsyncCallback(client.category.modify, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
}, },
}) })
const deleteCategory = useAsyncCallback(client.category.delete, { const deleteCategory = useAsyncCallback(client.category.delete, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToRootCategory()) dispatch(redirectToRootCategory())
}, },
}) })
const openDeleteCategoryModal = () => { const openDeleteCategoryModal = () => {
const categoryName = category?.name const categoryName = category?.name
openConfirmModal({ openConfirmModal({
title: <Trans>Delete Category</Trans>, title: <Trans>Delete Category</Trans>,
children: ( children: (
<Text size="sm"> <Text size="sm">
<Trans> <Trans>
Are you sure you want to delete category <Code>{categoryName}</Code>? Are you sure you want to delete category <Code>{categoryName}</Code>?
</Trans> </Trans>
</Text> </Text>
), ),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> }, labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm: async () => await deleteCategory.execute({ id: +id }), onConfirm: async () => await deleteCategory.execute({ id: +id }),
}) })
} }
useEffect(() => { useEffect(() => {
if (!category) return if (!category) return
setValues({ setValues({
id: +category.id, id: +category.id,
name: category.name, name: category.name,
parentId: category.parentId, parentId: category.parentId,
position: category.position, position: category.position,
}) })
}, [setValues, category]) }, [setValues, category])
const editable = id !== Constants.categories.all.id && id !== Constants.categories.starred.id const editable = id !== Constants.categories.all.id && id !== Constants.categories.starred.id
if (!category) return <Loader /> if (!category) return <Loader />
return ( return (
<Container> <Container>
{modifyCategory.error && ( {modifyCategory.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(modifyCategory.error)} /> <Alert messages={errorToStrings(modifyCategory.error)} />
</Box> </Box>
)} )}
{deleteCategory.error && ( {deleteCategory.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(deleteCategory.error)} /> <Alert messages={errorToStrings(deleteCategory.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(modifyCategory.execute)}> <form onSubmit={form.onSubmit(modifyCategory.execute)}>
<Stack> <Stack>
<Title order={3}>{category.name}</Title> <Title order={3}>{category.name}</Title>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}> <Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box> <Box>
{apiKey && ( {apiKey && (
<Anchor <Anchor
href={`rest/category/entriesAsFeed?id=${category.id}&apiKey=${apiKey}`} href={`rest/category/entriesAsFeed?id=${category.id}&apiKey=${apiKey}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<Trans>Link</Trans> <Trans>Link</Trans>
</Anchor> </Anchor>
)} )}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>} {!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
{editable && ( {editable && (
<> <>
<Divider /> <Divider />
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required /> <TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect <CategorySelect
label={<Trans>Parent Category</Trans>} label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")} {...form.getInputProps("parentId")}
clearable clearable
withoutCategoryIds={[id]} withoutCategoryIds={[id]}
/> />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} /> <NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
</> </>
)} )}
<Group> <Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
{editable && ( {editable && (
<> <>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}> <Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Button <Button
color="red" color="red"
leftSection={<TbTrash size={16} />} leftSection={<TbTrash size={16} />}
onClick={() => openDeleteCategoryModal()} onClick={() => openDeleteCategoryModal()}
loading={deleteCategory.loading} loading={deleteCategory.loading}
> >
<Trans>Delete</Trans> <Trans>Delete</Trans>
</Button> </Button>
</> </>
)} )}
</Group> </Group>
</Stack> </Stack>
</form> </form>
</Container> </Container>
) )
} }

View File

@@ -1,186 +1,186 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core" import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals" import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks" import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { type FeedModificationRequest } from "app/types" import type { FeedModificationRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { CategorySelect } from "components/content/add/CategorySelect" import { Loader } from "components/Loader"
import { Loader } from "components/Loader" import { RelativeDate } from "components/RelativeDate"
import { RelativeDate } from "components/RelativeDate" import { CategorySelect } from "components/content/add/CategorySelect"
import { useEffect } from "react" import { useEffect } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook" import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb" import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
function FilteringExpressionDescription() { function FilteringExpressionDescription() {
const example = <Code>url.contains('youtube') or (author eq 'athou' and title.contains('github'))</Code> const example = <Code>url.contains('youtube') or (author eq 'athou' and title.contains('github'))</Code>
return ( return (
<div> <div>
<div> <div>
<Trans> <Trans>
If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read
automatically. automatically.
</Trans> </Trans>
</div> </div>
<div> <div>
<Trans> <Trans>
Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case
to ease string comparison. to ease string comparison.
</Trans> </Trans>
</div> </div>
<div> <div>
<Trans>Example: {example}.</Trans> <Trans>Example: {example}.</Trans>
</div> </div>
<div> <div>
<Trans> <Trans>
<span>Complete syntax is available </span> <span>Complete syntax is available </span>
<a href="https://commons.apache.org/proper/commons-jexl/reference/syntax.html" target="_blank" rel="noreferrer"> <a href="https://commons.apache.org/proper/commons-jexl/reference/syntax.html" target="_blank" rel="noreferrer">
here here
</a> </a>
. .
</Trans> </Trans>
</div> </div>
</div> </div>
) )
} }
export function FeedDetailsPage() { export function FeedDetailsPage() {
const { id } = useParams() const { id } = useParams()
if (!id) throw Error("id required") if (!id) throw Error("id required")
const apiKey = useAppSelector(state => state.user.profile?.apiKey) const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const query = useAsync(async () => await client.feed.get(id), [id]) const query = useAsync(async () => await client.feed.get(id), [id])
const feed = query.result?.data const feed = query.result?.data
const form = useForm<FeedModificationRequest>() const form = useForm<FeedModificationRequest>()
const { setValues } = form const { setValues } = form
const modifyFeed = useAsyncCallback(client.feed.modify, { const modifyFeed = useAsyncCallback(client.feed.modify, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
}, },
}) })
const unsubscribe = useAsyncCallback(client.feed.unsubscribe, { const unsubscribe = useAsyncCallback(client.feed.unsubscribe, {
onSuccess: () => { onSuccess: () => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToRootCategory()) dispatch(redirectToRootCategory())
}, },
}) })
const openUnsubscribeModal = () => { const openUnsubscribeModal = () => {
const feedName = feed?.name const feedName = feed?.name
openConfirmModal({ openConfirmModal({
title: <Trans>Unsubscribe</Trans>, title: <Trans>Unsubscribe</Trans>,
children: ( children: (
<Text size="sm"> <Text size="sm">
<Trans> <Trans>
Are you sure you want to unsubscribe from <Code>{feedName}</Code>? Are you sure you want to unsubscribe from <Code>{feedName}</Code>?
</Trans> </Trans>
</Text> </Text>
), ),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> }, labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm: async () => await unsubscribe.execute({ id: +id }), onConfirm: async () => await unsubscribe.execute({ id: +id }),
}) })
} }
useEffect(() => { useEffect(() => {
if (!feed) return if (!feed) return
setValues(feed) setValues(feed)
}, [setValues, feed]) }, [setValues, feed])
if (!feed) return <Loader /> if (!feed) return <Loader />
return ( return (
<Container> <Container>
{modifyFeed.error && ( {modifyFeed.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(modifyFeed.error)} /> <Alert messages={errorToStrings(modifyFeed.error)} />
</Box> </Box>
)} )}
{unsubscribe.error && ( {unsubscribe.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(unsubscribe.error)} /> <Alert messages={errorToStrings(unsubscribe.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(modifyFeed.execute)}> <form onSubmit={form.onSubmit(modifyFeed.execute)}>
<Stack> <Stack>
<Title order={3}>{feed.name}</Title> <Title order={3}>{feed.name}</Title>
<Input.Wrapper label={<Trans>Feed URL</Trans>}> <Input.Wrapper label={<Trans>Feed URL</Trans>}>
<Box> <Box>
<Anchor href={feed.feedUrl} target="_blank" rel="noreferrer"> <Anchor href={feed.feedUrl} target="_blank" rel="noreferrer">
{feed.feedUrl} {feed.feedUrl}
</Anchor> </Anchor>
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Input.Wrapper label={<Trans>Website</Trans>}> <Input.Wrapper label={<Trans>Website</Trans>}>
<Box> <Box>
<Anchor href={feed.feedLink} target="_blank" rel="noreferrer"> <Anchor href={feed.feedLink} target="_blank" rel="noreferrer">
{feed.feedLink} {feed.feedLink}
</Anchor> </Anchor>
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Input.Wrapper label={<Trans>Last refresh</Trans>}> <Input.Wrapper label={<Trans>Last refresh</Trans>}>
<Box> <Box>
<RelativeDate date={feed.lastRefresh} /> <RelativeDate date={feed.lastRefresh} />
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Input.Wrapper label={<Trans>Last refresh message</Trans>}> <Input.Wrapper label={<Trans>Last refresh message</Trans>}>
<Box>{feed.message ?? <Trans>N/A</Trans>}</Box> <Box>{feed.message ?? <Trans>N/A</Trans>}</Box>
</Input.Wrapper> </Input.Wrapper>
<Input.Wrapper label={<Trans>Next refresh</Trans>}> <Input.Wrapper label={<Trans>Next refresh</Trans>}>
<Box> <Box>
<RelativeDate date={feed.nextRefresh} /> <RelativeDate date={feed.nextRefresh} />
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}> <Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box> <Box>
{apiKey && ( {apiKey && (
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer"> <Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
<Trans>Link</Trans> <Trans>Link</Trans>
</Anchor> </Anchor>
)} )}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>} {!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Divider /> <Divider />
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required /> <TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable /> <CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} /> <NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
<TextInput <TextInput
label={<Trans>Filtering expression</Trans>} label={<Trans>Filtering expression</Trans>}
description={<FilteringExpressionDescription />} description={<FilteringExpressionDescription />}
{...form.getInputProps("filter")} {...form.getInputProps("filter")}
/> />
<Group> <Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyFeed.loading}> <Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyFeed.loading}>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Button <Button
color="red" color="red"
leftSection={<TbTrash size={16} />} leftSection={<TbTrash size={16} />}
onClick={() => openUnsubscribeModal()} onClick={() => openUnsubscribeModal()}
loading={unsubscribe.loading} loading={unsubscribe.loading}
> >
<Trans>Unsubscribe</Trans> <Trans>Unsubscribe</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</Container> </Container>
) )
} }

View File

@@ -1,101 +1,102 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core" import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks" import { useViewportSize } from "@mantine/hooks"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { type EntrySourceType } from "app/entries/slice" import type { EntrySourceType } from "app/entries/slice"
import { loadEntries } from "app/entries/thunks" import { loadEntries } from "app/entries/thunks"
import { redirectToCategoryDetails, redirectToFeedDetails, redirectToTagDetails } from "app/redirect/thunks" import { redirectToCategoryDetails, redirectToFeedDetails, redirectToTagDetails } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { flattenCategoryTree } from "app/utils" import { flattenCategoryTree } from "app/utils"
import { FeedEntries } from "components/content/FeedEntries" import { FeedEntries } from "components/content/FeedEntries"
import { useEffect } from "react" import { useEffect } from "react"
import { TbEdit } from "react-icons/tb" import { TbEdit } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom" import { useLocation, useParams } from "react-router-dom"
import { tss } from "tss" import { tss } from "tss"
function NoSubscriptionHelp() { function NoSubscriptionHelp() {
return ( return (
<Box> <Box>
<Center> <Center>
<Trans> <Trans>
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page? You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
</Trans> </Trans>
</Center> </Center>
</Box> </Box>
) )
} }
interface FeedEntriesPageProps { interface FeedEntriesPageProps {
sourceType: EntrySourceType sourceType: EntrySourceType
} }
const useStyles = tss.create(() => ({ const useStyles = tss.create(() => ({
sourceWebsiteLink: { sourceWebsiteLink: {
color: "inherit", color: "inherit",
textDecoration: "none", textDecoration: "none",
}, },
})) }))
export function FeedEntriesPage(props: FeedEntriesPageProps) { export function FeedEntriesPage(props: FeedEntriesPageProps) {
const { classes } = useStyles() const { classes } = useStyles()
const location = useLocation() const location = useLocation()
const { id = Constants.categories.all.id } = useParams() const { id = Constants.categories.all.id } = useParams()
const viewport = useViewportSize() const viewport = useViewportSize()
const theme = useMantineTheme() const theme = useMantineTheme()
const rootCategory = useAppSelector(state => state.tree.rootCategory) const rootCategory = useAppSelector(state => state.tree.rootCategory)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel) const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl) const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
const hasMore = useAppSelector(state => state.entries.hasMore) const hasMore = useAppSelector(state => state.entries.hasMore)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const titleClicked = () => { const titleClicked = () => {
switch (props.sourceType) { switch (props.sourceType) {
case "category": case "category":
dispatch(redirectToCategoryDetails(id)) dispatch(redirectToCategoryDetails(id))
break break
case "feed": case "feed":
dispatch(redirectToFeedDetails(id)) dispatch(redirectToFeedDetails(id))
break break
case "tag": case "tag":
dispatch(redirectToTagDetails(id)) dispatch(redirectToTagDetails(id))
break break
} }
} }
useEffect(() => { // biome-ignore lint/correctness/useExhaustiveDependencies: we subscribe to state.timestamp because we want to reload entries even if the props are the same
dispatch( useEffect(() => {
loadEntries({ dispatch(
source: { loadEntries({
type: props.sourceType, source: {
id, type: props.sourceType,
}, id,
clearSearch: true, },
}) clearSearch: true,
) })
}, [dispatch, props.sourceType, id, location.state]) )
}, [dispatch, props.sourceType, id, location.state?.timestamp])
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
if (noSubscriptions) return <NoSubscriptionHelp /> const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
return ( if (noSubscriptions) return <NoSubscriptionHelp />
// add some room at the bottom of the page in order to be able to scroll the current entry at the top of the page when expanding return (
<Box mb={viewport.height * 0.75}> // add some room at the bottom of the page in order to be able to scroll the current entry at the top of the page when expanding
<Group gap="xl"> <Box mb={viewport.height * 0.75}>
{sourceWebsiteUrl && ( <Group gap="xl">
<a href={sourceWebsiteUrl} target="_blank" rel="noreferrer" className={classes.sourceWebsiteLink}> {sourceWebsiteUrl && (
<Title order={3}>{sourceLabel}</Title> <a href={sourceWebsiteUrl} target="_blank" rel="noreferrer" className={classes.sourceWebsiteLink}>
</a> <Title order={3}>{sourceLabel}</Title>
)} </a>
{!sourceWebsiteUrl && <Title order={3}>{sourceLabel}</Title>} )}
{sourceLabel && ( {!sourceWebsiteUrl && <Title order={3}>{sourceLabel}</Title>}
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}> {sourceLabel && (
<TbEdit size={18} /> <ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
</ActionIcon> <TbEdit size={18} />
)} </ActionIcon>
</Group> )}
</Group>
<FeedEntries />
<FeedEntries />
{!hasMore && <Divider my="xl" label={<Trans>No more entries</Trans>} labelPosition="center" />}
</Box> {!hasMore && <Divider my="xl" label={<Trans>No more entries</Trans>} labelPosition="center" />}
) </Box>
} )
}

View File

@@ -1,217 +1,217 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { ActionIcon, AppShell, Box, Center, Group, ScrollArea, Title, useMantineTheme } from "@mantine/core" import { ActionIcon, AppShell, Box, Center, Group, ScrollArea, Title, useMantineTheme } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectToAdd, redirectToRootCategory } from "app/redirect/thunks" import { redirectToAdd, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { setMobileMenuOpen } from "app/tree/slice" import { setMobileMenuOpen } from "app/tree/slice"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { reloadProfile, reloadSettings, reloadTags } from "app/user/thunks" import { reloadProfile, reloadSettings, reloadTags } from "app/user/thunks"
import { ActionButton } from "components/ActionButton" import { ActionButton } from "components/ActionButton"
import { AnnouncementDialog } from "components/AnnouncementDialog" import { AnnouncementDialog } from "components/AnnouncementDialog"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { Logo } from "components/Logo" import { Logo } from "components/Logo"
import { OnDesktop } from "components/responsive/OnDesktop" import { OnDesktop } from "components/responsive/OnDesktop"
import { OnMobile } from "components/responsive/OnMobile" import { OnMobile } from "components/responsive/OnMobile"
import { useAppLoading } from "hooks/useAppLoading" import { useAppLoading } from "hooks/useAppLoading"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { useWebSocket } from "hooks/useWebSocket" import { useWebSocket } from "hooks/useWebSocket"
import { LoadingPage } from "pages/LoadingPage" import { LoadingPage } from "pages/LoadingPage"
import { type ReactNode, Suspense, useEffect } from "react" import { type ReactNode, Suspense, useEffect } from "react"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { TbMenu2, TbPlus, TbX } from "react-icons/tb" import { TbMenu2, TbPlus, TbX } from "react-icons/tb"
import { Outlet } from "react-router-dom" import { Outlet } from "react-router-dom"
import { useSwipeable } from "react-swipeable" import { useSwipeable } from "react-swipeable"
import { tss } from "tss" import { tss } from "tss"
import useLocalStorage from "use-local-storage" import useLocalStorage from "use-local-storage"
interface LayoutProps { interface LayoutProps {
sidebar: ReactNode sidebar: ReactNode
sidebarVisible: boolean sidebarVisible: boolean
header: ReactNode header: ReactNode
} }
function LogoAndTitle() { function LogoAndTitle() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return ( return (
<Center inline onClick={async () => await dispatch(redirectToRootCategory())} style={{ cursor: "pointer" }}> <Center inline onClick={async () => await dispatch(redirectToRootCategory())} style={{ cursor: "pointer" }}>
<Logo size={24} /> <Logo size={24} />
<Title order={3} pl="md"> <Title order={3} pl="md">
CommaFeed CommaFeed
</Title> </Title>
</Center> </Center>
) )
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
sidebarWidth: number sidebarWidth: number
sidebarPadding: string sidebarPadding: string
sidebarRightBorderWidth: string sidebarRightBorderWidth: string
}>() }>()
.create(({ sidebarWidth, sidebarPadding, sidebarRightBorderWidth }) => { .create(({ sidebarWidth, sidebarPadding, sidebarRightBorderWidth }) => {
return { return {
sidebarContent: { sidebarContent: {
maxWidth: `calc(${sidebarWidth}px - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`, maxWidth: `calc(${sidebarWidth}px - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`,
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: { [`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
maxWidth: `calc(100vw - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`, maxWidth: `calc(100vw - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`,
}, },
}, },
} }
}) })
export default function Layout(props: LayoutProps) { export default function Layout(props: LayoutProps) {
const theme = useMantineTheme() const theme = useMantineTheme()
const mobile = useMobile() const mobile = useMobile()
const { isBrowserExtensionPopup } = useBrowserExtension() const { isBrowserExtensionPopup } = useBrowserExtension()
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar-width", 350) const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar-width", 350)
const sidebarPadding = theme.spacing.xs const sidebarPadding = theme.spacing.xs
const { classes } = useStyles({ const { classes } = useStyles({
sidebarWidth, sidebarWidth,
sidebarPadding, sidebarPadding,
sidebarRightBorderWidth: "1px", sidebarRightBorderWidth: "1px",
}) })
const { loading } = useAppLoading() const { loading } = useAppLoading()
const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen) const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen)
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected) const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval) const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter) const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
useWebSocket() useWebSocket()
useEffect(() => { useEffect(() => {
// load initial data // load initial data
dispatch(reloadSettings()) dispatch(reloadSettings())
dispatch(reloadProfile()) dispatch(reloadProfile())
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(reloadTags()) dispatch(reloadTags())
}, [dispatch]) }, [dispatch])
useEffect(() => { useEffect(() => {
let timer: number | undefined let timer: number | undefined
if (!webSocketConnected && treeReloadInterval) { if (!webSocketConnected && treeReloadInterval) {
// reload tree periodically if not receiving websocket events // reload tree periodically if not receiving websocket events
timer = window.setInterval(async () => await dispatch(reloadTree()), treeReloadInterval) timer = window.setInterval(async () => await dispatch(reloadTree()), treeReloadInterval)
} }
return () => clearInterval(timer) return () => clearInterval(timer)
}, [dispatch, webSocketConnected, treeReloadInterval]) }, [dispatch, webSocketConnected, treeReloadInterval])
const burger = ( const burger = (
<ActionButton <ActionButton
label={mobileMenuOpen ? <Trans>Close menu</Trans> : <Trans>Open menu</Trans>} label={mobileMenuOpen ? <Trans>Close menu</Trans> : <Trans>Open menu</Trans>}
icon={mobileMenuOpen ? <TbX size={18} /> : <TbMenu2 size={18} />} icon={mobileMenuOpen ? <TbX size={18} /> : <TbMenu2 size={18} />}
onClick={() => dispatch(setMobileMenuOpen(!mobileMenuOpen))} onClick={() => dispatch(setMobileMenuOpen(!mobileMenuOpen))}
></ActionButton> />
) )
const addButton = ( const addButton = (
<ActionIcon <ActionIcon
color={theme.primaryColor} color={theme.primaryColor}
variant="subtle" variant="subtle"
onClick={async () => await dispatch(redirectToAdd())} onClick={async () => await dispatch(redirectToAdd())}
aria-label="Subscribe" aria-label="Subscribe"
> >
<TbPlus size={18} /> <TbPlus size={18} />
</ActionIcon> </ActionIcon>
) )
const header = ( const header = (
<> <>
<OnMobile> <OnMobile>
{mobileMenuOpen && ( {mobileMenuOpen && (
<Group justify="space-between" p="md"> <Group justify="space-between" p="md">
<Box>{burger}</Box> <Box>{burger}</Box>
<Box> <Box>
<LogoAndTitle /> <LogoAndTitle />
</Box> </Box>
<Box>{addButton}</Box> <Box>{addButton}</Box>
</Group> </Group>
)} )}
{!mobileMenuOpen && ( {!mobileMenuOpen && (
<Group p="md"> <Group p="md">
<Box>{burger}</Box> <Box>{burger}</Box>
<Box style={{ flexGrow: 1 }}>{props.header}</Box> <Box style={{ flexGrow: 1 }}>{props.header}</Box>
</Group> </Group>
)} )}
</OnMobile> </OnMobile>
<OnDesktop> <OnDesktop>
<Group p="md"> <Group p="md">
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}> <Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
<Box> <Box>
<LogoAndTitle /> <LogoAndTitle />
</Box> </Box>
<Box>{addButton}</Box> <Box>{addButton}</Box>
</Group> </Group>
<Box style={{ flexGrow: 1 }}>{props.header}</Box> <Box style={{ flexGrow: 1 }}>{props.header}</Box>
</Group> </Group>
</OnDesktop> </OnDesktop>
</> </>
) )
const swipeHandlers = useSwipeable({ const swipeHandlers = useSwipeable({
onSwiping: e => { onSwiping: e => {
const threshold = document.documentElement.clientWidth / 6 const threshold = document.documentElement.clientWidth / 6
if (e.absX > threshold) { if (e.absX > threshold) {
dispatch(setMobileMenuOpen(e.dir === "Right")) dispatch(setMobileMenuOpen(e.dir === "Right"))
} }
}, },
}) })
if (loading) return <LoadingPage /> if (loading) return <LoadingPage />
return ( return (
<Box {...swipeHandlers}> <Box {...swipeHandlers}>
<AppShell <AppShell
header={{ height: Constants.layout.headerHeight, collapsed: headerInFooter }} header={{ height: Constants.layout.headerHeight, collapsed: headerInFooter }}
footer={{ height: Constants.layout.headerHeight, collapsed: !headerInFooter }} footer={{ height: Constants.layout.headerHeight, collapsed: !headerInFooter }}
navbar={{ navbar={{
width: sidebarWidth, width: sidebarWidth,
breakpoint: Constants.layout.mobileBreakpoint, breakpoint: Constants.layout.mobileBreakpoint,
collapsed: { mobile: !mobileMenuOpen, desktop: !props.sidebarVisible }, collapsed: { mobile: !mobileMenuOpen, desktop: !props.sidebarVisible },
}} }}
padding={{ base: 6, [Constants.layout.mobileBreakpointName]: "md" }} padding={{ base: 6, [Constants.layout.mobileBreakpointName]: "md" }}
> >
<AppShell.Header id={Constants.dom.headerId}>{!headerInFooter && header}</AppShell.Header> <AppShell.Header id={Constants.dom.headerId}>{!headerInFooter && header}</AppShell.Header>
<AppShell.Footer id={Constants.dom.footerId}>{headerInFooter && header}</AppShell.Footer> <AppShell.Footer id={Constants.dom.footerId}>{headerInFooter && header}</AppShell.Footer>
<AppShell.Navbar id="sidebar" p={sidebarPadding}> <AppShell.Navbar id="sidebar" p={sidebarPadding}>
<AppShell.Section grow component={ScrollArea} mx="-sm" px="sm"> <AppShell.Section grow component={ScrollArea} mx="-sm" px="sm">
<Box className={classes.sidebarContent}>{props.sidebar}</Box> <Box className={classes.sidebarContent}>{props.sidebar}</Box>
</AppShell.Section> </AppShell.Section>
</AppShell.Navbar> </AppShell.Navbar>
<OnDesktop> <OnDesktop>
<Draggable <Draggable
axis="x" axis="x"
defaultPosition={{ defaultPosition={{
x: sidebarWidth, x: sidebarWidth,
y: 0, y: 0,
}} }}
bounds={{ bounds={{
left: 120, left: 120,
right: 1000, right: 1000,
}} }}
grid={[30, 30]} grid={[30, 30]}
onDrag={(_e, data) => setSidebarWidth(data.x)} onDrag={(_e, data) => setSidebarWidth(data.x)}
> >
<Box <Box
style={{ style={{
position: "fixed", position: "fixed",
height: "100%", height: "100%",
width: "10px", width: "10px",
cursor: "ew-resize", cursor: "ew-resize",
}} }}
></Box> />
</Draggable> </Draggable>
</OnDesktop> </OnDesktop>
<AppShell.Main id="content"> <AppShell.Main id="content">
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<AnnouncementDialog /> <AnnouncementDialog />
<Outlet /> <Outlet />
</Suspense> </Suspense>
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
</Box> </Box>
) )
} }

View File

@@ -1,38 +1,38 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Container, Tabs } from "@mantine/core" import { Container, Tabs } from "@mantine/core"
import { CustomCodeSettings } from "components/settings/CustomCodeSettings" import { CustomCodeSettings } from "components/settings/CustomCodeSettings"
import { DisplaySettings } from "components/settings/DisplaySettings" import { DisplaySettings } from "components/settings/DisplaySettings"
import { ProfileSettings } from "components/settings/ProfileSettings" import { ProfileSettings } from "components/settings/ProfileSettings"
import { TbCode, TbPhoto, TbUser } from "react-icons/tb" import { TbCode, TbPhoto, TbUser } from "react-icons/tb"
export function SettingsPage() { export function SettingsPage() {
return ( return (
<Container size="sm" px={0}> <Container size="sm" px={0}>
<Tabs defaultValue="display" keepMounted={false}> <Tabs defaultValue="display" keepMounted={false}>
<Tabs.List> <Tabs.List>
<Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}> <Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}>
<Trans>Display</Trans> <Trans>Display</Trans>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}> <Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}>
<Trans>Custom code</Trans> <Trans>Custom code</Trans>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<TbUser size={16} />}> <Tabs.Tab value="profile" leftSection={<TbUser size={16} />}>
<Trans>Profile</Trans> <Trans>Profile</Trans>
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="display" pt="xl"> <Tabs.Panel value="display" pt="xl">
<DisplaySettings /> <DisplaySettings />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="customCode" pt="xl"> <Tabs.Panel value="customCode" pt="xl">
<CustomCodeSettings /> <CustomCodeSettings />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="profile" pt="xl"> <Tabs.Panel value="profile" pt="xl">
<ProfileSettings /> <ProfileSettings />
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
</Container> </Container>
) )
} }

View File

@@ -1,42 +1,42 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Container, Group, Input, Stack, Title } from "@mantine/core" import { Anchor, Box, Button, Container, Group, Input, Stack, Title } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectToSelectedSource } from "app/redirect/thunks" import { redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
export function TagDetailsPage() { export function TagDetailsPage() {
const { id = Constants.categories.all.id } = useParams() const { id = Constants.categories.all.id } = useParams()
const apiKey = useAppSelector(state => state.user.profile?.apiKey) const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return ( return (
<Container> <Container>
<Stack> <Stack>
<Title order={3}>{id}</Title> <Title order={3}>{id}</Title>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}> <Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box> <Box>
{apiKey && ( {apiKey && (
<Anchor <Anchor
href={`rest/category/entriesAsFeed?id=${Constants.categories.all.id}&apiKey=${apiKey}&tag=${id}`} href={`rest/category/entriesAsFeed?id=${Constants.categories.all.id}&apiKey=${apiKey}&tag=${id}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<Trans>Link</Trans> <Trans>Link</Trans>
</Anchor> </Anchor>
)} )}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>} {!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>
<Group> <Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Container> </Container>
) )
} }

View File

@@ -1,89 +1,89 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core" import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { redirectToRootCategory } from "app/redirect/thunks" import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type LoginRequest } from "app/types" import type { LoginRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle" import { PageTitle } from "pages/PageTitle"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
export function LoginPage() { export function LoginPage() {
const serverInfos = useAppSelector(state => state.server.serverInfos) const serverInfos = useAppSelector(state => state.server.serverInfos)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const form = useForm<LoginRequest>({ const form = useForm<LoginRequest>({
initialValues: { initialValues: {
name: "", name: "",
password: "", password: "",
}, },
}) })
const login = useAsyncCallback(client.user.login, { const login = useAsyncCallback(client.user.login, {
onSuccess: () => { onSuccess: () => {
dispatch(redirectToRootCategory()) dispatch(redirectToRootCategory())
}, },
}) })
return ( return (
<Container size="xs"> <Container size="xs">
<PageTitle /> <PageTitle />
<Paper> <Paper>
<Title order={2} mb="md"> <Title order={2} mb="md">
<Trans>Log in</Trans> <Trans>Log in</Trans>
</Title> </Title>
{login.error && ( {login.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(login.error)} /> <Alert messages={errorToStrings(login.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(login.execute)}> <form onSubmit={form.onSubmit(login.execute)}>
<Stack> <Stack>
<TextInput <TextInput
label={<Trans>User Name or E-mail</Trans>} label={<Trans>User Name or E-mail</Trans>}
placeholder={t`User Name or E-mail`} placeholder={t`User Name or E-mail`}
{...form.getInputProps("name")} {...form.getInputProps("name")}
description={ description={
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : "" serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
} }
size="md" size="md"
required required
autoCapitalize="off" autoCapitalize="off"
/> />
<PasswordInput <PasswordInput
label={<Trans>Password</Trans>} label={<Trans>Password</Trans>}
placeholder={t`Password`} placeholder={t`Password`}
{...form.getInputProps("password")} {...form.getInputProps("password")}
size="md" size="md"
required required
/> />
{serverInfos?.smtpEnabled && ( {serverInfos?.smtpEnabled && (
<Anchor component={Link} to="/passwordRecovery" c="dimmed"> <Anchor component={Link} to="/passwordRecovery" c="dimmed">
<Trans>Forgot password?</Trans> <Trans>Forgot password?</Trans>
</Anchor> </Anchor>
)} )}
<Button type="submit" loading={login.loading}> <Button type="submit" loading={login.loading}>
<Trans>Log in</Trans> <Trans>Log in</Trans>
</Button> </Button>
{serverInfos?.allowRegistrations && ( {serverInfos?.allowRegistrations && (
<Center> <Center>
<Group> <Group>
<Trans> <Trans>
<Box>Need an account?</Box> <Box>Need an account?</Box>
<Anchor component={Link} to="/register"> <Anchor component={Link} to="/register">
Sign up! Sign up!
</Anchor> </Anchor>
</Trans> </Trans>
</Group> </Group>
</Center> </Center>
)} )}
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
</Container> </Container>
) )
} }

View File

@@ -1,79 +1,79 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, Stack, TextInput, Title } from "@mantine/core" import { Anchor, Box, Button, Center, Container, Group, Paper, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { type PasswordResetRequest } from "app/types" import type { PasswordResetRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle" import { PageTitle } from "pages/PageTitle"
import { useState } from "react" import { useState } from "react"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
export function PasswordRecoveryPage() { export function PasswordRecoveryPage() {
const [message, setMessage] = useState("") const [message, setMessage] = useState("")
const form = useForm<PasswordResetRequest>({ const form = useForm<PasswordResetRequest>({
initialValues: { initialValues: {
email: "", email: "",
}, },
}) })
const recoverPassword = useAsyncCallback(client.user.passwordReset, { const recoverPassword = useAsyncCallback(client.user.passwordReset, {
onSuccess: () => { onSuccess: () => {
setMessage(t`An email has been sent if this address was registered. Check your inbox.`) setMessage(t`An email has been sent if this address was registered. Check your inbox.`)
}, },
}) })
return ( return (
<Container size="xs"> <Container size="xs">
<PageTitle /> <PageTitle />
<Paper> <Paper>
<Title order={2} mb="md"> <Title order={2} mb="md">
<Trans>Password Recovery</Trans> <Trans>Password Recovery</Trans>
</Title> </Title>
{recoverPassword.error && ( {recoverPassword.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(recoverPassword.error)} /> <Alert messages={errorToStrings(recoverPassword.error)} />
</Box> </Box>
)} )}
{message && ( {message && (
<Box mb="md"> <Box mb="md">
<Alert level="success" messages={[message]} /> <Alert level="success" messages={[message]} />
</Box> </Box>
)} )}
<form <form
onSubmit={form.onSubmit(req => { onSubmit={form.onSubmit(req => {
setMessage("") setMessage("")
recoverPassword.execute(req) recoverPassword.execute(req)
})} })}
> >
<Stack> <Stack>
<TextInput <TextInput
type="email" type="email"
label={<Trans>E-mail</Trans>} label={<Trans>E-mail</Trans>}
placeholder={t`E-mail`} placeholder={t`E-mail`}
{...form.getInputProps("email")} {...form.getInputProps("email")}
size="md" size="md"
required required
/> />
<Button type="submit" loading={recoverPassword.loading}> <Button type="submit" loading={recoverPassword.loading}>
<Trans>Recover password</Trans> <Trans>Recover password</Trans>
</Button> </Button>
<Center> <Center>
<Group> <Group>
<Anchor component={Link} to="/login"> <Anchor component={Link} to="/login">
<Trans>Back to log in</Trans> <Trans>Back to log in</Trans>
</Anchor> </Anchor>
</Group> </Group>
</Center> </Center>
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
</Container> </Container>
) )
} }

View File

@@ -1,89 +1,89 @@
import { t, Trans } from "@lingui/macro" import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core" import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { redirectToRootCategory } from "app/redirect/thunks" import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { type RegistrationRequest } from "app/types" import type { RegistrationRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle" import { PageTitle } from "pages/PageTitle"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
export function RegistrationPage() { export function RegistrationPage() {
const serverInfos = useAppSelector(state => state.server.serverInfos) const serverInfos = useAppSelector(state => state.server.serverInfos)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const form = useForm<RegistrationRequest>({ const form = useForm<RegistrationRequest>({
initialValues: { initialValues: {
name: "", name: "",
password: "", password: "",
email: "", email: "",
}, },
}) })
const register = useAsyncCallback(client.user.register, { const register = useAsyncCallback(client.user.register, {
onSuccess: () => { onSuccess: () => {
dispatch(redirectToRootCategory()) dispatch(redirectToRootCategory())
}, },
}) })
return ( return (
<Container size="xs"> <Container size="xs">
<PageTitle /> <PageTitle />
<Paper> <Paper>
<Title order={2} mb="md"> <Title order={2} mb="md">
<Trans>Sign up</Trans> <Trans>Sign up</Trans>
</Title> </Title>
{serverInfos && !serverInfos.allowRegistrations && ( {serverInfos && !serverInfos.allowRegistrations && (
<Box mb="md"> <Box mb="md">
<Alert messages={[t`Registrations are closed on this CommaFeed instance`]} /> <Alert messages={[t`Registrations are closed on this CommaFeed instance`]} />
</Box> </Box>
)} )}
{serverInfos?.allowRegistrations && ( {serverInfos?.allowRegistrations && (
<> <>
{register.error && ( {register.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(register.error)} /> <Alert messages={errorToStrings(register.error)} />
</Box> </Box>
)} )}
<form onSubmit={form.onSubmit(register.execute)}> <form onSubmit={form.onSubmit(register.execute)}>
<Stack> <Stack>
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required /> <TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
<TextInput <TextInput
type="email" type="email"
label={<Trans>E-mail address</Trans>} label={<Trans>E-mail address</Trans>}
placeholder={t`E-mail address`} placeholder={t`E-mail address`}
{...form.getInputProps("email")} {...form.getInputProps("email")}
size="md" size="md"
required required
/> />
<PasswordInput <PasswordInput
label={<Trans>Password</Trans>} label={<Trans>Password</Trans>}
placeholder={t`Password`} placeholder={t`Password`}
{...form.getInputProps("password")} {...form.getInputProps("password")}
size="md" size="md"
required required
/> />
<Button type="submit" loading={register.loading}> <Button type="submit" loading={register.loading}>
<Trans>Sign up</Trans> <Trans>Sign up</Trans>
</Button> </Button>
<Center> <Center>
<Group> <Group>
<Trans> <Trans>
<Box>Have an account?</Box> <Box>Have an account?</Box>
<Anchor component={Link} to="/login"> <Anchor component={Link} to="/login">
Log in! Log in!
</Anchor> </Anchor>
</Trans> </Trans>
</Group> </Group>
</Center> </Center>
</Stack> </Stack>
</form> </form>
</> </>
)} )}
</Paper> </Paper>
</Container> </Container>
) )
} }

View File

@@ -1,14 +1,14 @@
import { useMantineTheme } from "@mantine/core" import { useMantineTheme } from "@mantine/core"
import { useColorScheme } from "hooks/useColorScheme" import { useColorScheme } from "hooks/useColorScheme"
import { createTss } from "tss-react" import { createTss } from "tss-react"
const useContext = () => { const useContext = () => {
// return anything here that will be accessible in tss.create() // return anything here that will be accessible in tss.create()
const theme = useMantineTheme() const theme = useMantineTheme()
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
return { theme, colorScheme } return { theme, colorScheme }
} }
export const { tss } = createTss({ useContext }) export const { tss } = createTss({ useContext })

Some files were not shown because too many files have changed in this diff Show More