forked from Archives/Athou_commafeed
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd06055246 | ||
|
|
62c1f25ffc | ||
|
|
415dc15d6c | ||
|
|
1d0c87c679 | ||
|
|
e51c486a04 | ||
|
|
73808c1a70 | ||
|
|
fbcc2ecd0f | ||
|
|
3997606774 | ||
|
|
b988b599d5 | ||
|
|
3e2ff2959d | ||
|
|
5714a63d27 | ||
|
|
12b18d1e04 | ||
|
|
232141cb56 | ||
|
|
c4334e5e6e | ||
|
|
ddf78f880b | ||
|
|
b3651f3fba | ||
|
|
24943b868c | ||
|
|
ef71a691ef | ||
|
|
01593d94eb | ||
|
|
b793cc66d1 | ||
|
|
3810dedf47 | ||
|
|
9115797dee | ||
|
|
232658b934 | ||
|
|
f99fe57695 | ||
|
|
ec89d41112 | ||
|
|
f6d26a77cc | ||
|
|
860852cc12 | ||
|
|
d06d76401c | ||
|
|
f5b04a783e | ||
|
|
964e470951 | ||
|
|
612f8722dd | ||
|
|
e118dc9b7f | ||
|
|
6e42cdaf2d | ||
|
|
5198792ca5 | ||
|
|
10a71213f3 | ||
|
|
a5d0979d9f | ||
|
|
d84225ab1c | ||
|
|
cd86947e64 | ||
|
|
f6b3114a91 | ||
|
|
cd50b6b058 | ||
|
|
b0c7ef18db | ||
|
|
24171faf86 | ||
|
|
941f14dd41 | ||
|
|
d46ef787db | ||
|
|
ec7447a38c | ||
|
|
2a3fc3ae15 | ||
|
|
ef25582bcb | ||
|
|
55bbb2542d | ||
|
|
8e94ac74a8 | ||
|
|
90ecb9253c | ||
|
|
6721842d98 | ||
|
|
8b487ec414 | ||
|
|
d6382861c3 | ||
|
|
2cdea99a69 | ||
|
|
b1ae1c8afd | ||
|
|
c09cd0c717 | ||
|
|
f50e0ae272 | ||
|
|
b99b91a2a8 | ||
|
|
d9759de6f1 | ||
|
|
cf2b7f9e4f | ||
|
|
ee880c06ed | ||
|
|
bc2e13ef22 | ||
|
|
39ecfe2782 | ||
|
|
3295d82f69 | ||
|
|
1cd27a59e2 | ||
|
|
e1602edff1 | ||
|
|
ef8e61d6fc | ||
|
|
0057030442 | ||
|
|
6fabe46d6e | ||
|
|
37c58f2755 | ||
|
|
bb982c3caf | ||
|
|
7e4c3737a8 | ||
|
|
23596b5ac6 | ||
|
|
2fdeb7acd8 | ||
|
|
c62cac478c | ||
|
|
e9026e0371 | ||
|
|
7446d906ae | ||
|
|
62ad09ac93 | ||
|
|
01d1f920a8 | ||
|
|
057810470c | ||
|
|
5a6d6be8e5 | ||
|
|
c6c813a4ee | ||
|
|
ad5787a38b | ||
|
|
387ceabf30 | ||
|
|
ffe6962c36 | ||
|
|
6d599fc77d | ||
|
|
9fcff1342c | ||
|
|
f7dbc2e9aa | ||
|
|
468f2e4c76 | ||
|
|
883c9c79aa | ||
|
|
f171d05088 | ||
|
|
f85745fe40 | ||
|
|
5ad93bb3ba | ||
|
|
d80ed9d4dd | ||
|
|
69b5f5418a | ||
|
|
06aa37659c | ||
|
|
d5c98de839 | ||
|
|
920975059c | ||
|
|
7c6e4c3356 | ||
|
|
143971da5e | ||
|
|
8976e9c01a | ||
|
|
20c6355efd | ||
|
|
f86f38ef7a | ||
|
|
24311df551 | ||
|
|
d02aa78def | ||
|
|
b131020f46 | ||
|
|
4ab82782b0 | ||
|
|
6f9ebd5d78 | ||
|
|
7ebbf26369 | ||
|
|
dbc93f9928 | ||
|
|
ad6ebd7e4d | ||
|
|
ab86247c8c | ||
|
|
884516be28 | ||
|
|
c236b1adda | ||
|
|
222117dafe | ||
|
|
38cd27df57 | ||
|
|
5e07e74bb2 | ||
|
|
fe779e361f | ||
|
|
1a51799497 | ||
|
|
6ea926cdb0 | ||
|
|
439d61946a | ||
|
|
426c8d7dfb | ||
|
|
f1b51e8342 |
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "maven"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 50
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/commafeed-client"
|
|
||||||
schedule:
|
|
||||||
interval: "monthly"
|
|
||||||
open-pull-requests-limit: 50
|
|
||||||
groups:
|
|
||||||
mantine:
|
|
||||||
patterns:
|
|
||||||
- "@mantine/*"
|
|
||||||
lingui:
|
|
||||||
patterns:
|
|
||||||
- "@lingui/*"
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -40,6 +40,14 @@ jobs:
|
|||||||
name: commafeed.jar
|
name: commafeed.jar
|
||||||
path: commafeed-server/target/commafeed.jar
|
path: commafeed-server/target/commafeed.jar
|
||||||
|
|
||||||
|
- name: Upload Playwright artifacts
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-artifacts
|
||||||
|
path: |
|
||||||
|
**/target/playwright-artifacts/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -54,7 +62,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
tags: |
|
tags: |
|
||||||
athou/commafeed:latest
|
athou/commafeed:latest
|
||||||
athou/commafeed:${{ github.ref_name }}
|
athou/commafeed:${{ github.ref_name }}
|
||||||
@@ -65,7 +73,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
tags: athou/commafeed:master
|
tags: athou/commafeed:master
|
||||||
|
|
||||||
# Create GitHub release after Docker image has been published
|
# Create GitHub release after Docker image has been published
|
||||||
|
|||||||
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
Binary file not shown.
6
.mvn/wrapper/maven-wrapper.properties
vendored
6
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -6,7 +6,7 @@
|
|||||||
# "License"); you may not use this file except in compliance
|
# "License"); you may not use this file except in compliance
|
||||||
# with the License. You may obtain a copy of the License at
|
# with the License. You may obtain a copy of the License at
|
||||||
#
|
#
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing,
|
# Unless required by applicable law or agreed to in writing,
|
||||||
# software distributed under the License is distributed on an
|
# software distributed under the License is distributed on an
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
# KIND, either express or implied. See the License for the
|
# KIND, either express or implied. See the License for the
|
||||||
# specific language governing permissions and limitations
|
# specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.3/apache-maven-3.8.3-bin.zip
|
distributionType=only-script
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [4.4.1]
|
||||||
|
|
||||||
|
- fix vertical scrolling issues with Safari (#1168)
|
||||||
|
- the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is
|
||||||
|
now "on desktop" instead of "always"
|
||||||
|
- the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389)
|
||||||
|
- remove a superfluous feed fetch when subscribing to a feed (#1431)
|
||||||
|
- the Docker image now uses Java 21
|
||||||
|
|
||||||
## [4.4.0]
|
## [4.4.0]
|
||||||
|
|
||||||
- add support for sharing using the browser native capabilities if available (#1255)
|
- add support for sharing using the browser native capabilities if available (#1255)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM eclipse-temurin:17-jre
|
FROM eclipse-temurin:21.0.3_9-jre
|
||||||
|
|
||||||
EXPOSE 8082
|
EXPOSE 8082
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 140,
|
|
||||||
"semi": false,
|
|
||||||
"tabWidth": 4,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "auto",
|
|
||||||
"trailingComma": "es5"
|
|
||||||
}
|
|
||||||
19
commafeed-client/biome.json
Normal file
19
commafeed-client/biome.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
17814
commafeed-client/package-lock.json
generated
17814
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,83 +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.27",
|
"@emotion/react": "^11.11.4",
|
||||||
"@lingui/core": "^4.10.0",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@lingui/macro": "^4.10.0",
|
"@lingui/core": "^4.11.1",
|
||||||
"@lingui/react": "^4.10.0",
|
"@lingui/macro": "^4.11.1",
|
||||||
"@mantine/core": "^7.8.0",
|
"@lingui/react": "^4.11.1",
|
||||||
"@mantine/form": "^7.8.0",
|
"@mantine/core": "^7.10.2",
|
||||||
"@mantine/hooks": "^7.8.0",
|
"@mantine/form": "^7.10.2",
|
||||||
"@mantine/modals": "^7.8.0",
|
"@mantine/hooks": "^7.10.2",
|
||||||
"@mantine/notifications": "^7.8.0",
|
"@mantine/modals": "^7.10.2",
|
||||||
"@mantine/spotlight": "^7.8.0",
|
"@mantine/notifications": "^7.10.2",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@mantine/spotlight": "^7.10.2",
|
||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"axios": "^1.6.8",
|
"@reduxjs/toolkit": "^2.2.5",
|
||||||
"dayjs": "^1.11.10",
|
"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.47.0",
|
"interweave": "^13.1.0",
|
||||||
"mousetrap": "^1.6.5",
|
"monaco-editor": "^0.49.0",
|
||||||
"react": "^18.2.0",
|
"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-dom": "^18.2.0",
|
"react-contexify": "^6.0.0",
|
||||||
"react-draggable": "^4.4.6",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-ga4": "^2.1.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.0.1",
|
"react-draggable": "^4.4.6",
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-ga4": "^2.1.0",
|
||||||
"react-redux": "^9.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-icons": "^5.2.1",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-infinite-scroller": "^1.2.6",
|
||||||
"redoc": "^2.1.3",
|
"react-redux": "^9.1.2",
|
||||||
"throttle-debounce": "^5.0.0",
|
"react-router-dom": "^6.23.1",
|
||||||
"tinycon": "^0.6.8",
|
"react-swipeable": "^7.0.1",
|
||||||
"tss-react": "^4.9.6",
|
"redoc": "^2.1.5",
|
||||||
"use-local-storage": "^3.0.0",
|
"throttle-debounce": "^5.0.0",
|
||||||
"websocket-heartbeat-js": "^1.1.3"
|
"tinycon": "^0.6.8",
|
||||||
},
|
"tss-react": "^4.9.10",
|
||||||
"devDependencies": {
|
"use-local-storage": "^3.0.0",
|
||||||
"@lingui/cli": "^4.10.0",
|
"vite-plugin-biome": "^1.0.10",
|
||||||
"@lingui/vite-plugin": "^4.10.0",
|
"websocket-heartbeat-js": "^1.1.3"
|
||||||
"@types/mousetrap": "^1.6.15",
|
},
|
||||||
"@types/react": "^18.2.78",
|
"devDependencies": {
|
||||||
"@types/react-dom": "^18.2.25",
|
"@biomejs/biome": "^1.8.1",
|
||||||
"@types/react-infinite-scroller": "^1.2.5",
|
"@lingui/cli": "^4.11.1",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@lingui/vite-plugin": "^4.11.1",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/mousetrap": "^1.6.15",
|
||||||
"@types/tinycon": "^0.6.5",
|
"@types/react": "^18.3.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@types/react-helmet": "^6.1.11",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"@types/react-infinite-scroller": "^1.2.5",
|
||||||
"eslint": "^8.57.0",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"eslint-config-love": "^47.0.0",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"@types/tinycon": "^0.6.5",
|
||||||
"eslint-config-standard": "^17.1.0",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
"eslint-plugin-react": "^7.34.1",
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"typescript": "^5.4.5",
|
||||||
"prettier": "^3.2.5",
|
"vite": "^5.3.1",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"vite-tsconfig-paths": "^4.3.2",
|
||||||
"typescript": "^5.4.5",
|
"vitest": "^1.6.0",
|
||||||
"vite": "^5.2.8",
|
"vitest-mock-extended": "^1.3.1"
|
||||||
"vite-plugin-eslint": "^1.8.1",
|
}
|
||||||
"vite-tsconfig-paths": "^4.3.2",
|
|
||||||
"vitest": "^1.5.0",
|
|
||||||
"vitest-mock-extended": "^1.3.1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed</artifactId>
|
<artifactId>commafeed</artifactId>
|
||||||
<version>4.4.0</version>
|
<version>4.4.1</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>commafeed-client</artifactId>
|
<artifactId>commafeed-client</artifactId>
|
||||||
<name>CommaFeed Client</name>
|
<name>CommaFeed Client</name>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
import { I18nProvider } from "@lingui/react"
|
import { I18nProvider } from "@lingui/react"
|
||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider } from "@mantine/core"
|
||||||
import { useDidUpdate } from "@mantine/hooks"
|
|
||||||
import { ModalsProvider } from "@mantine/modals"
|
import { ModalsProvider } from "@mantine/modals"
|
||||||
import { Notifications } from "@mantine/notifications"
|
import { Notifications } from "@mantine/notifications"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "app/constants"
|
||||||
@@ -9,11 +8,13 @@ import { redirectTo } from "app/redirect/slice"
|
|||||||
import { reloadServerInfos } from "app/server/thunks"
|
import { reloadServerInfos } from "app/server/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useAppDispatch, useAppSelector } from "app/store"
|
||||||
import { categoryUnreadCount } from "app/utils"
|
import { categoryUnreadCount } from "app/utils"
|
||||||
|
import { DisablePullToRefresh } from "components/DisablePullToRefresh"
|
||||||
import { ErrorBoundary } from "components/ErrorBoundary"
|
import { ErrorBoundary } from "components/ErrorBoundary"
|
||||||
import { Header } from "components/header/Header"
|
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,9 +29,10 @@ 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, useRef } from "react"
|
import { isSafari } from "react-device-detect"
|
||||||
import ReactGA from "react-ga4"
|
import ReactGA from "react-ga4"
|
||||||
|
import { Helmet } from "react-helmet"
|
||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
||||||
import Tinycon from "tinycon"
|
import Tinycon from "tinycon"
|
||||||
|
|
||||||
@@ -166,38 +168,13 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomJs() {
|
function CustomCode() {
|
||||||
const scriptLoaded = useRef(false)
|
return (
|
||||||
|
<Helmet>
|
||||||
// useDidUpdate is used instead of useEffect because we want to skip the first render
|
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
||||||
// the first render is the render of react-router, the routes are actually loaded in a second render
|
<script type="text/javascript" src="custom_js.js" />
|
||||||
// we want the script to be executed when the first route is done loading
|
</Helmet>
|
||||||
useDidUpdate(() => {
|
)
|
||||||
if (scriptLoaded.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement("script")
|
|
||||||
script.src = "custom_js.js"
|
|
||||||
script.async = true
|
|
||||||
document.body.appendChild(script)
|
|
||||||
|
|
||||||
scriptLoaded.current = true
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomCss() {
|
|
||||||
useEffect(() => {
|
|
||||||
const link = document.createElement("link")
|
|
||||||
link.rel = "stylesheet"
|
|
||||||
link.type = "text/css"
|
|
||||||
link.href = "custom_css.css"
|
|
||||||
document.head.appendChild(link)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -217,8 +194,12 @@ export function App() {
|
|||||||
<GoogleAnalyticsHandler />
|
<GoogleAnalyticsHandler />
|
||||||
<RedirectHandler />
|
<RedirectHandler />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
<CustomJs />
|
<CustomCode />
|
||||||
<CustomCss />
|
{/* disable pull-to-refresh as it messes with vertical scrolling
|
||||||
|
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
||||||
|
https://github.com/Athou/commafeed/issues/1168
|
||||||
|
*/}
|
||||||
|
{!isSafari && <DisablePullToRefresh />}
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</>
|
</>
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|||||||
@@ -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
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
15
commafeed-client/src/components/DisablePullToRefresh.tsx
Normal file
15
commafeed-client/src/components/DisablePullToRefresh.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Helmet } from "react-helmet"
|
||||||
|
|
||||||
|
export const DisablePullToRefresh = () => {
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
<style type="text/css">
|
||||||
|
{`
|
||||||
|
html, body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</Helmet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,222 +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 { Constants } from "app/constants"
|
import { useOs } from "@mantine/hooks"
|
||||||
|
import { Constants } from "app/constants"
|
||||||
export function KeyboardShortcutsHelp() {
|
|
||||||
return (
|
export function KeyboardShortcutsHelp() {
|
||||||
<Stack gap="xs">
|
const isMacOS = useOs() === "macos"
|
||||||
<Table striped highlightOnHover>
|
return (
|
||||||
<Table.Tbody>
|
<Stack gap="xs">
|
||||||
<Table.Tr>
|
<Table striped highlightOnHover>
|
||||||
<Table.Td>
|
<Table.Tbody>
|
||||||
<Trans>Refresh</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Refresh</Trans>
|
||||||
<Kbd>R</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>R</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open next entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open next entry</Trans>
|
||||||
<Kbd>J</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>J</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open previous entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open previous entry</Trans>
|
||||||
<Kbd>K</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>K</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Set focus on next entry without opening it</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Set focus on next entry without opening it</Trans>
|
||||||
<Kbd>N</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>N</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Set focus on previous entry without opening it</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Set focus on previous entry without opening it</Trans>
|
||||||
<Kbd>P</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>P</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Move the page down</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Move the page down</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Space</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Space</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Move the page up</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Move the page up</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Shift</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
<span> + </span>
|
<Trans>Shift</Trans>
|
||||||
<Kbd>
|
</Kbd>
|
||||||
<Trans>Space</Trans>
|
<span> + </span>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Space</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open/close current entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open/close current entry</Trans>
|
||||||
<Kbd>O</Kbd>
|
</Table.Td>
|
||||||
<span>, </span>
|
<Table.Td>
|
||||||
<Kbd>
|
<Kbd>O</Kbd>
|
||||||
<Trans>Enter</Trans>
|
<span>, </span>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Enter</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open current entry in a new tab</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open current entry in a new tab</Trans>
|
||||||
<Kbd>V</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>V</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open current entry in a new tab in the background</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open current entry in a new tab in the background</Trans>
|
||||||
<Kbd>B</Kbd>
|
</Table.Td>
|
||||||
<span>*, </span>
|
<Table.Td>
|
||||||
<Kbd>
|
<Kbd>B</Kbd>
|
||||||
<Trans>Middle click</Trans>
|
<span>*, </span>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Middle click</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Toggle read status of current entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Toggle read status of current entry</Trans>
|
||||||
<Kbd>M</Kbd>
|
</Table.Td>
|
||||||
<span>, </span>
|
<Table.Td>
|
||||||
<Trans>Swipe header to the left</Trans>
|
<Kbd>M</Kbd>
|
||||||
</Table.Td>
|
<span>, </span>
|
||||||
</Table.Tr>
|
<Trans>Swipe header to the left</Trans>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Toggle starred status of current entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Toggle starred status of current entry</Trans>
|
||||||
<Kbd>S</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>S</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Mark all entries as read</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Mark all entries as read</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Shift</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
<span> + </span>
|
<Trans>Shift</Trans>
|
||||||
<Kbd>A</Kbd>
|
</Kbd>
|
||||||
</Table.Td>
|
<span> + </span>
|
||||||
</Table.Tr>
|
<Kbd>A</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Go to the All view</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Go to the All view</Trans>
|
||||||
<Kbd>G</Kbd>
|
</Table.Td>
|
||||||
<span> </span>
|
<Table.Td>
|
||||||
<Kbd>A</Kbd>
|
<Kbd>G</Kbd>
|
||||||
</Table.Td>
|
<span> </span>
|
||||||
</Table.Tr>
|
<Kbd>A</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Navigate to a subscription by entering its name</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Navigate to a subscription by entering its name</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Ctrl</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
<span> + </span>
|
<Trans>{isMacOS ? "Cmd" : "Ctrl"}</Trans>
|
||||||
<Kbd>K</Kbd>
|
</Kbd>
|
||||||
<span>, </span>
|
<span> + </span>
|
||||||
<Kbd>G</Kbd>
|
<Kbd>K</Kbd>
|
||||||
<span> </span>
|
<span>, </span>
|
||||||
<Kbd>U</Kbd>
|
<Kbd>G</Kbd>
|
||||||
</Table.Td>
|
<span> </span>
|
||||||
</Table.Tr>
|
<Kbd>U</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Show entry menu (desktop)</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Show entry menu (desktop)</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Right click</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Right click</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Show native menu (desktop)</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Show native menu (desktop)</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Shift</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
<span> + </span>
|
<Trans>Shift</Trans>
|
||||||
<Kbd>
|
</Kbd>
|
||||||
<Trans>Right click</Trans>
|
<span> + </span>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Right click</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Show entry menu (mobile)</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Show entry menu (mobile)</Trans>
|
||||||
<Kbd>
|
</Table.Td>
|
||||||
<Trans>Long press</Trans>
|
<Table.Td>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Long press</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Toggle sidebar</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Toggle sidebar</Trans>
|
||||||
<Kbd>F</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>F</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Show keyboard shortcut help</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Show keyboard shortcut help</Trans>
|
||||||
<Kbd>?</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>?</Kbd>
|
||||||
</Table.Tbody>
|
</Table.Td>
|
||||||
</Table>
|
</Table.Tr>
|
||||||
<Box>
|
</Table.Tbody>
|
||||||
<span>* </span>
|
</Table>
|
||||||
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
|
<Box>
|
||||||
<Trans>Browser extension required for Chrome</Trans>
|
<span>* </span>
|
||||||
</Anchor>
|
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
|
||||||
</Box>
|
<Trans>Browser extension required for Chrome</Trans>
|
||||||
</Stack>
|
</Anchor>
|
||||||
)
|
</Box>
|
||||||
}
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> · </span>}
|
{props.entry.author && props.entry.categories && <span> · </span>}
|
||||||
{props.entry.categories && <span>{props.entry.categories}</span>}
|
{props.entry.categories && <span>{props.entry.categories}</span>}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +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 { Spotlight, spotlight, type SpotlightActionData } from "@mantine/spotlight"
|
import { useOs } from "@mantine/hooks"
|
||||||
import { redirectToFeed } from "app/redirect/thunks"
|
import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
|
||||||
import { useAppDispatch } from "app/store"
|
import { redirectToFeed } from "app/redirect/thunks"
|
||||||
import { type Subscription } from "app/types"
|
import { useAppDispatch } from "app/store"
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
import type { Subscription } from "app/types"
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||||
import { TbSearch } from "react-icons/tb"
|
import { useMousetrap } from "hooks/useMousetrap"
|
||||||
|
import { TbSearch } from "react-icons/tb"
|
||||||
export interface TreeSearchProps {
|
|
||||||
feeds: Subscription[]
|
export interface TreeSearchProps {
|
||||||
}
|
feeds: Subscription[]
|
||||||
|
}
|
||||||
export function TreeSearch(props: TreeSearchProps) {
|
|
||||||
const dispatch = useAppDispatch()
|
export function TreeSearch(props: TreeSearchProps) {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
const actions: SpotlightActionData[] = props.feeds
|
const isMacOS = useOs() === "macos"
|
||||||
.map(f => ({
|
const actions: SpotlightActionData[] = props.feeds
|
||||||
id: `${f.id}`,
|
.map(f => ({
|
||||||
label: f.name,
|
id: `${f.id}`,
|
||||||
leftSection: <FeedFavicon url={f.iconUrl} />,
|
label: f.name,
|
||||||
onClick: async () => await dispatch(redirectToFeed(f.id)),
|
leftSection: <FeedFavicon url={f.iconUrl} />,
|
||||||
}))
|
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 rightSection = (
|
const searchIcon = <TbSearch size={18} />
|
||||||
<Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}>
|
const rightSection = (
|
||||||
<Kbd>Ctrl</Kbd>
|
<Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}>
|
||||||
<Box mx={5}>+</Box>
|
<Kbd>{isMacOS ? "Cmd" : "Ctrl"}</Kbd>
|
||||||
<Kbd>K</Kbd>
|
<Box mx={5}>+</Box>
|
||||||
</Center>
|
<Kbd>K</Kbd>
|
||||||
)
|
</Center>
|
||||||
|
)
|
||||||
// additional keyboard shortcut used by commafeed v1
|
|
||||||
useMousetrap("g u", () => spotlight.open())
|
// additional keyboard shortcut used by commafeed v1
|
||||||
|
useMousetrap("g u", () => spotlight.open())
|
||||||
return (
|
|
||||||
<>
|
return (
|
||||||
<TextInput
|
<>
|
||||||
placeholder={t`Search`}
|
<TextInput
|
||||||
leftSection={searchIcon}
|
placeholder={t`Search`}
|
||||||
rightSectionWidth={100}
|
leftSection={searchIcon}
|
||||||
rightSection={rightSection}
|
rightSectionWidth={100}
|
||||||
styles={{
|
rightSection={rightSection}
|
||||||
input: {
|
styles={{
|
||||||
cursor: "pointer",
|
input: {
|
||||||
},
|
cursor: "pointer",
|
||||||
}}
|
},
|
||||||
onClick={() => spotlight.open()}
|
}}
|
||||||
// prevent focus
|
onClick={() => spotlight.open()}
|
||||||
onFocus={e => e.target.blur()}
|
// prevent focus
|
||||||
readOnly
|
onFocus={e => e.target.blur()}
|
||||||
/>
|
readOnly
|
||||||
<Spotlight
|
/>
|
||||||
actions={actions}
|
<Spotlight
|
||||||
limit={10}
|
actions={actions}
|
||||||
shortcut="ctrl+k"
|
limit={10}
|
||||||
searchProps={{
|
shortcut="mod+k"
|
||||||
leftSection: searchIcon,
|
searchProps={{
|
||||||
placeholder: t`Search`,
|
leftSection: searchIcon,
|
||||||
}}
|
placeholder: t`Search`,
|
||||||
nothingFound={<Trans>Nothing found</Trans>}
|
}}
|
||||||
></Spotlight>
|
nothingFound={<Trans>Nothing found</Trans>}
|
||||||
</>
|
/>
|
||||||
)
|
</>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ msgstr "输入您当前的密码以更改配置文件设置"
|
|||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Entry headers"
|
msgid "Entry headers"
|
||||||
msgstr ""
|
msgstr "条目头部"
|
||||||
|
|
||||||
#: src/components/Alert.tsx
|
#: src/components/Alert.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
@@ -578,7 +578,7 @@ msgstr "没有更多条目"
|
|||||||
|
|
||||||
#: src/components/content/ShareButtons.tsx
|
#: src/components/content/ShareButtons.tsx
|
||||||
msgid "No sharing options available."
|
msgid "No sharing options available."
|
||||||
msgstr ""
|
msgstr "没有可用的分享选项"
|
||||||
|
|
||||||
#: src/components/sidebar/TreeSearch.tsx
|
#: src/components/sidebar/TreeSearch.tsx
|
||||||
msgid "Nothing found"
|
msgid "Nothing found"
|
||||||
@@ -590,11 +590,11 @@ msgstr "最早的优先"
|
|||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "On desktop"
|
msgid "On desktop"
|
||||||
msgstr ""
|
msgstr "桌面端"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "On mobile"
|
msgid "On mobile"
|
||||||
msgstr ""
|
msgstr "移动端"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||||
@@ -660,7 +660,7 @@ msgstr "OPML 文件"
|
|||||||
|
|
||||||
#: src/components/content/add/ImportOpml.tsx
|
#: src/components/content/add/ImportOpml.tsx
|
||||||
msgid "OPML file is required"
|
msgid "OPML file is required"
|
||||||
msgstr ""
|
msgstr "OPML 文件是必需的"
|
||||||
|
|
||||||
#: src/pages/app/AboutPage.tsx
|
#: src/pages/app/AboutPage.tsx
|
||||||
msgid "Order"
|
msgid "Order"
|
||||||
@@ -718,7 +718,7 @@ msgstr "此 CommaFeed 实例上的注册已关闭"
|
|||||||
|
|
||||||
#: src/pages/app/AboutPage.tsx
|
#: src/pages/app/AboutPage.tsx
|
||||||
msgid "REST API"
|
msgid "REST API"
|
||||||
msgstr ""
|
msgstr "REST API"
|
||||||
|
|
||||||
#: src/components/KeyboardShortcutsHelp.tsx
|
#: src/components/KeyboardShortcutsHelp.tsx
|
||||||
#: src/components/KeyboardShortcutsHelp.tsx
|
#: src/components/KeyboardShortcutsHelp.tsx
|
||||||
@@ -804,7 +804,7 @@ msgstr "显示条目菜单(移动端)"
|
|||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Show external link icon"
|
msgid "Show external link icon"
|
||||||
msgstr ""
|
msgstr "显示外部链接图标"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Show feeds and categories with no unread entries"
|
msgid "Show feeds and categories with no unread entries"
|
||||||
@@ -820,7 +820,7 @@ msgstr "显示原生菜单(桌面端)"
|
|||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Show star icon"
|
msgid "Show star icon"
|
||||||
msgstr ""
|
msgstr "显示星标图标"
|
||||||
|
|
||||||
#: src/pages/auth/RegistrationPage.tsx
|
#: src/pages/auth/RegistrationPage.tsx
|
||||||
#: src/pages/auth/RegistrationPage.tsx
|
#: src/pages/auth/RegistrationPage.tsx
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
html, body {
|
|
||||||
/* disable pull-to-refresh on mobile as it messes with vertical scrolling */
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import "@mantine/core/styles.css"
|
|||||||
import "@mantine/notifications/styles.css"
|
import "@mantine/notifications/styles.css"
|
||||||
import "@mantine/spotlight/styles.css"
|
import "@mantine/spotlight/styles.css"
|
||||||
import "react-contexify/ReactContexify.css"
|
import "react-contexify/ReactContexify.css"
|
||||||
import "main.css"
|
|
||||||
import { App } from "App"
|
import { App } from "App"
|
||||||
import { store } from "app/store"
|
import { store } from "app/store"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]} </span>
|
<span>{shownGauges[g]} </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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user