Compare commits

...

123 Commits
4.4.0 ... 4.4.1

Author SHA1 Message Date
Athou
cd06055246 release 4.4.1 2024-06-15 14:52:26 +02:00
Jérémie Panzer
62c1f25ffc Merge pull request #1448 from Athou/renovate/vite-5.x
Update dependency vite to ^5.3.1
2024-06-15 14:42:42 +02:00
Jérémie Panzer
415dc15d6c Merge pull request #1449 from Athou/renovate/mantine-monorepo
Update mantine monorepo to ^7.10.2
2024-06-15 14:41:52 +02:00
renovate[bot]
1d0c87c679 Update mantine monorepo to ^7.10.2 2024-06-15 12:32:58 +00:00
renovate[bot]
e51c486a04 Update dependency vite to ^5.3.1 2024-06-15 12:32:47 +00:00
Athou
73808c1a70 make renovate also bump versions in package.json 2024-06-15 14:32:05 +02:00
Athou
fbcc2ecd0f add a little delay to simulate a network operation 2024-06-15 14:00:15 +02:00
Athou
3997606774 looks like sometimes the websocket connection is not established on github actions, refresh the tree with an interval smaller than the timeout of playwright 2024-06-15 08:46:55 +02:00
Jérémie Panzer
b988b599d5 Merge pull request #1446 from Athou/renovate/org.apache.maven.plugins-maven-failsafe-plugin-3.x
Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.3.0
2024-06-14 22:31:42 +02:00
Jérémie Panzer
3e2ff2959d Merge pull request #1447 from Athou/renovate/org.apache.maven.plugins-maven-surefire-plugin-3.x
Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.3.0
2024-06-14 22:31:31 +02:00
renovate[bot]
5714a63d27 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.3.0 2024-06-14 19:55:02 +00:00
renovate[bot]
12b18d1e04 Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.3.0 2024-06-14 19:54:59 +00:00
Athou
232141cb56 fix UnsupportedTemporalTypeException when tests fail 2024-06-14 21:50:50 +02:00
Jérémie Panzer
c4334e5e6e Merge pull request #1445 from Athou/renovate/vite-5.x-lockfile
Update dependency vite to v5.3.1
2024-06-14 13:00:43 +02:00
renovate[bot]
ddf78f880b Update dependency vite to v5.3.1 2024-06-14 09:59:39 +00:00
Athou
b3651f3fba upload playwright artifacts on test failure to help debug what went wrong 2024-06-14 07:59:26 +02:00
Jérémie Panzer
24943b868c Merge pull request #1442 from Athou/renovate/mantine-monorepo
Update mantine monorepo to v7.10.2
2024-06-13 23:51:58 +02:00
renovate[bot]
ef71a691ef Update mantine monorepo to v7.10.2 2024-06-13 21:47:30 +00:00
Jérémie Panzer
01593d94eb Merge pull request #1443 from Athou/renovate/vite-5.x-lockfile
Update dependency vite to v5.3.0
2024-06-13 23:45:28 +02:00
renovate[bot]
b793cc66d1 Update dependency vite to v5.3.0 2024-06-13 21:31:42 +00:00
Athou
3810dedf47 replace complex eslint config with biome 2024-06-13 23:28:45 +02:00
Athou
9115797dee try to fix renovatebot warning about not being able to update commafeed-client 2024-06-13 06:53:23 +02:00
Athou
232658b934 remove commons-io since we already have guava 2024-06-12 17:17:54 +02:00
Athou
f99fe57695 remove 32bit arm7 because its support was dropped from temurin 21 2024-06-12 16:40:08 +02:00
Athou
ec89d41112 update mvn wrapper 2024-06-12 16:36:57 +02:00
Jérémie Panzer
f6d26a77cc Merge pull request #1439 from Athou/renovate/eclipse-temurin-21.x
Update eclipse-temurin Docker tag to v21
2024-06-12 16:33:07 +02:00
renovate[bot]
860852cc12 Update eclipse-temurin Docker tag to v21 2024-06-12 14:28:49 +00:00
Athou
d06d76401c download maven-wrapper binaries if needed 2024-06-12 16:24:26 +02:00
Athou
f5b04a783e remove commons-codec since we already have guava 2024-06-12 16:18:52 +02:00
Athou
964e470951 manually bump dependencies left behind by dependabot 2024-06-12 15:45:58 +02:00
Jérémie Panzer
612f8722dd Merge pull request #1433 from Athou/renovate/prettier-3.x-lockfile
Update dependency prettier to v3.3.2
2024-06-12 15:33:22 +02:00
Jérémie Panzer
e118dc9b7f Merge pull request #1434 from Athou/renovate/eclipse-temurin-17.x
Update eclipse-temurin Docker tag to v17.0.11_9-jre
2024-06-12 15:32:49 +02:00
renovate[bot]
6e42cdaf2d Update eclipse-temurin Docker tag to v17.0.11_9-jre 2024-06-12 13:28:00 +00:00
renovate[bot]
5198792ca5 Update dependency prettier to v3.3.2 2024-06-12 13:27:56 +00:00
Athou
10a71213f3 use renovate instead of dependabot 2024-06-12 15:27:48 +02:00
Jérémie Panzer
a5d0979d9f Merge pull request #1432 from Athou/renovate/configure
Configure Renovate
2024-06-12 15:27:13 +02:00
renovate[bot]
d84225ab1c Add renovate.json 2024-06-12 13:23:16 +00:00
Athou
cd86947e64 keep pull to refresh for safari (#1168) 2024-06-12 13:12:33 +02:00
Athou
f6b3114a91 use react helmet to manipulate scripts and styles programatically 2024-06-12 13:03:19 +02:00
Athou
cd50b6b058 use new playwright locators 2024-06-12 11:39:51 +02:00
Athou
b0c7ef18db fix test race condition 2024-06-12 10:30:47 +02:00
Athou
24171faf86 fetchFeedInternal follows redirects, we don't need to call it twice (#1431) 2024-06-12 08:21:11 +02:00
Jérémie Panzer
941f14dd41 Merge pull request #1374 from Athou/dependabot/npm_and_yarn/commafeed-client/eslint-plugin-react-hooks-4.6.2
Bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 in /commafeed-client
2024-06-11 08:37:23 +02:00
Jérémie Panzer
d46ef787db Merge pull request #1404 from Athou/dependabot/npm_and_yarn/commafeed-client/vitest-1.6.0
Bump vitest from 1.5.0 to 1.6.0 in /commafeed-client
2024-06-11 08:37:10 +02:00
Jérémie Panzer
ec7447a38c Merge pull request #1411 from Athou/dependabot/npm_and_yarn/commafeed-client/react-router-dom-6.23.1
Bump react-router-dom from 6.22.3 to 6.23.1 in /commafeed-client
2024-06-11 08:33:22 +02:00
dependabot[bot]
2a3fc3ae15 Bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 in /commafeed-client
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.0 to 4.6.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 06:31:59 +00:00
dependabot[bot]
ef25582bcb Bump react-router-dom from 6.22.3 to 6.23.1 in /commafeed-client
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.22.3 to 6.23.1.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.23.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 06:29:29 +00:00
Jérémie Panzer
55bbb2542d Merge pull request #1402 from Athou/dependabot/npm_and_yarn/commafeed-client/axios-1.7.2
Bump axios from 1.6.8 to 1.7.2 in /commafeed-client
2024-06-11 08:28:51 +02:00
Jérémie Panzer
8e94ac74a8 Merge pull request #1401 from Athou/dependabot/npm_and_yarn/commafeed-client/react-redux-9.1.2
Bump react-redux from 9.1.0 to 9.1.2 in /commafeed-client
2024-06-11 08:28:42 +02:00
Jérémie Panzer
90ecb9253c Merge pull request #1416 from Athou/dependabot/npm_and_yarn/commafeed-client/eslint-plugin-react-7.34.2
Bump eslint-plugin-react from 7.34.1 to 7.34.2 in /commafeed-client
2024-06-11 08:28:26 +02:00
Jérémie Panzer
6721842d98 Merge pull request #1414 from Athou/dependabot/npm_and_yarn/commafeed-client/lingui-240f3c17ef
Bump the lingui group across 1 directory with 5 updates
2024-06-11 08:28:15 +02:00
Jérémie Panzer
8b487ec414 Merge pull request #1428 from Athou/dependabot/npm_and_yarn/commafeed-client/typescript-eslint/eslint-plugin-7.13.0
Bump @typescript-eslint/eslint-plugin from 7.6.0 to 7.13.0 in /commafeed-client
2024-06-11 08:28:03 +02:00
dependabot[bot]
d6382861c3 Bump the lingui group across 1 directory with 5 updates
Bumps the lingui group with 5 updates in the /commafeed-client directory:

| Package | From | To |
| --- | --- | --- |
| [@lingui/core](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/macro](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/react](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/cli](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/vite-plugin](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |



Updates `@lingui/core` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/macro` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/react` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/cli` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/vite-plugin` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

---
updated-dependencies:
- dependency-name: "@lingui/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/macro"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/react"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/vite-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: lingui
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:27:42 +00:00
dependabot[bot]
2cdea99a69 Bump axios from 1.6.8 to 1.7.2 in /commafeed-client
Bumps [axios](https://github.com/axios/axios) from 1.6.8 to 1.7.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.8...v1.7.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:26:35 +00:00
dependabot[bot]
b1ae1c8afd Bump react-redux from 9.1.0 to 9.1.2 in /commafeed-client
Bumps [react-redux](https://github.com/reduxjs/react-redux) from 9.1.0 to 9.1.2.
- [Release notes](https://github.com/reduxjs/react-redux/releases)
- [Changelog](https://github.com/reduxjs/react-redux/blob/master/CHANGELOG.md)
- [Commits](https://github.com/reduxjs/react-redux/compare/v9.1.0...v9.1.2)

---
updated-dependencies:
- dependency-name: react-redux
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:26:07 +00:00
Jérémie Panzer
c09cd0c717 Merge pull request #1379 from Athou/dependabot/npm_and_yarn/commafeed-client/dayjs-1.11.11
Bump dayjs from 1.11.10 to 1.11.11 in /commafeed-client
2024-06-11 07:26:00 +02:00
Jérémie Panzer
f50e0ae272 Merge pull request #1375 from Athou/dependabot/npm_and_yarn/commafeed-client/multi-ec30208de6
Bump react-dom and @types/react-dom in /commafeed-client
2024-06-11 07:25:51 +02:00
Jérémie Panzer
b99b91a2a8 Merge pull request #1384 from Athou/dependabot/npm_and_yarn/commafeed-client/tss-react-4.9.10
Bump tss-react from 4.9.6 to 4.9.10 in /commafeed-client
2024-06-11 07:25:28 +02:00
Jérémie Panzer
d9759de6f1 Merge pull request #1403 from Athou/dependabot/npm_and_yarn/commafeed-client/monaco-editor-0.49.0
Bump monaco-editor from 0.47.0 to 0.49.0 in /commafeed-client
2024-06-11 07:25:19 +02:00
dependabot[bot]
cf2b7f9e4f Bump eslint-plugin-react from 7.34.1 to 7.34.2 in /commafeed-client
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.34.1 to 7.34.2.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.34.1...v7.34.2)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:25:14 +00:00
Jérémie Panzer
ee880c06ed Merge pull request #1407 from Athou/dependabot/npm_and_yarn/commafeed-client/react-icons-5.2.1
Bump react-icons from 5.0.1 to 5.2.1 in /commafeed-client
2024-06-11 07:25:10 +02:00
Jérémie Panzer
bc2e13ef22 Merge pull request #1410 from Athou/dependabot/npm_and_yarn/commafeed-client/reduxjs/toolkit-2.2.5
Bump @reduxjs/toolkit from 2.2.3 to 2.2.5 in /commafeed-client
2024-06-11 07:25:01 +02:00
dependabot[bot]
39ecfe2782 Bump @typescript-eslint/eslint-plugin in /commafeed-client
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.6.0 to 7.13.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.13.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:24:54 +00:00
Jérémie Panzer
3295d82f69 Merge pull request #1371 from Athou/dependabot/npm_and_yarn/commafeed-client/fontsource/open-sans-5.0.28
Bump @fontsource/open-sans from 5.0.27 to 5.0.28 in /commafeed-client
2024-06-11 07:24:42 +02:00
dependabot[bot]
1cd27a59e2 Bump vitest from 1.5.0 to 1.6.0 in /commafeed-client
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v1.6.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:24:35 +00:00
Jérémie Panzer
e1602edff1 Merge pull request #1426 from Athou/dependabot/npm_and_yarn/commafeed-client/redoc-2.1.5
Bump redoc from 2.1.3 to 2.1.5 in /commafeed-client
2024-06-11 07:24:03 +02:00
Jérémie Panzer
ef8e61d6fc Merge pull request #1427 from Athou/dependabot/npm_and_yarn/commafeed-client/prettier-3.3.1
Bump prettier from 3.2.5 to 3.3.1 in /commafeed-client
2024-06-11 07:23:54 +02:00
Jérémie Panzer
0057030442 Merge pull request #1429 from Athou/dependabot/npm_and_yarn/commafeed-client/vite-5.2.13
Bump vite from 5.2.8 to 5.2.13 in /commafeed-client
2024-06-11 07:23:33 +02:00
Jérémie Panzer
6fabe46d6e Merge pull request #1430 from Athou/dependabot/npm_and_yarn/commafeed-client/vitejs/plugin-react-4.3.1
Bump @vitejs/plugin-react from 4.2.1 to 4.3.1 in /commafeed-client
2024-06-11 07:23:21 +02:00
dependabot[bot]
37c58f2755 Bump monaco-editor from 0.47.0 to 0.49.0 in /commafeed-client
Bumps [monaco-editor](https://github.com/microsoft/monaco-editor) from 0.47.0 to 0.49.0.
- [Release notes](https://github.com/microsoft/monaco-editor/releases)
- [Changelog](https://github.com/microsoft/monaco-editor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/monaco-editor/compare/v0.47.0...v0.49.0)

---
updated-dependencies:
- dependency-name: monaco-editor
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:18:41 +00:00
dependabot[bot]
bb982c3caf Bump @reduxjs/toolkit from 2.2.3 to 2.2.5 in /commafeed-client
Bumps [@reduxjs/toolkit](https://github.com/reduxjs/redux-toolkit) from 2.2.3 to 2.2.5.
- [Release notes](https://github.com/reduxjs/redux-toolkit/releases)
- [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.2.3...v2.2.5)

---
updated-dependencies:
- dependency-name: "@reduxjs/toolkit"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:18:39 +00:00
Jérémie Panzer
7e4c3737a8 Merge pull request #1415 from Athou/dependabot/npm_and_yarn/commafeed-client/mantine-1f5c67be2f
Bump the mantine group across 1 directory with 6 updates
2024-06-11 07:17:44 +02:00
dependabot[bot]
23596b5ac6 Bump @vitejs/plugin-react from 4.2.1 to 4.3.1 in /commafeed-client
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.2.1 to 4.3.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:12:52 +00:00
dependabot[bot]
2fdeb7acd8 Bump vite from 5.2.8 to 5.2.13 in /commafeed-client
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.8 to 5.2.13.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.2.13/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.13/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:12:29 +00:00
dependabot[bot]
c62cac478c Bump the mantine group across 1 directory with 6 updates
Bumps the mantine group with 6 updates in the /commafeed-client directory:

| Package | From | To |
| --- | --- | --- |
| [@mantine/core](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/core) | `7.8.0` | `7.10.1` |
| [@mantine/form](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/form) | `7.8.0` | `7.10.1` |
| [@mantine/hooks](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/hooks) | `7.8.0` | `7.10.1` |
| [@mantine/modals](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/modals) | `7.8.0` | `7.10.1` |
| [@mantine/notifications](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/notifications) | `7.8.0` | `7.10.1` |
| [@mantine/spotlight](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/spotlight) | `7.8.0` | `7.10.1` |



Updates `@mantine/core` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/core)

Updates `@mantine/form` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/form)

Updates `@mantine/hooks` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/hooks)

Updates `@mantine/modals` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/modals)

Updates `@mantine/notifications` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/notifications)

Updates `@mantine/spotlight` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/spotlight)

---
updated-dependencies:
- dependency-name: "@mantine/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/form"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/hooks"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/modals"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/notifications"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/spotlight"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:12:05 +00:00
dependabot[bot]
e9026e0371 Bump prettier from 3.2.5 to 3.3.1 in /commafeed-client
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:11:50 +00:00
dependabot[bot]
7446d906ae Bump redoc from 2.1.3 to 2.1.5 in /commafeed-client
Bumps [redoc](https://github.com/Redocly/redoc) from 2.1.3 to 2.1.5.
- [Release notes](https://github.com/Redocly/redoc/releases)
- [Changelog](https://github.com/Redocly/redoc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Redocly/redoc/compare/v2.1.3...v2.1.5)

---
updated-dependencies:
- dependency-name: redoc
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:11:42 +00:00
Jérémie Panzer
62ad09ac93 Merge pull request #1358 from Athou/dependabot/maven/com.ibm.icu-icu4j-75.1
Bump com.ibm.icu:icu4j from 74.2 to 75.1
2024-06-11 07:10:33 +02:00
Jérémie Panzer
01d1f920a8 Merge pull request #1391 from Athou/dependabot/maven/com.microsoft.playwright-playwright-1.44.0
Bump com.microsoft.playwright:playwright from 1.43.0 to 1.44.0
2024-06-11 07:10:25 +02:00
Jérémie Panzer
057810470c Merge pull request #1392 from Athou/dependabot/maven/io.swagger.core.v3-swagger-annotations-2.2.22
Bump io.swagger.core.v3:swagger-annotations from 2.2.21 to 2.2.22
2024-06-11 07:10:18 +02:00
Jérémie Panzer
5a6d6be8e5 Merge pull request #1394 from Athou/dependabot/maven/com.google.apis-google-api-services-youtube-v3-rev20240514-2.0.0
Bump com.google.apis:google-api-services-youtube from v3-rev20240310-2.0.0 to v3-rev20240514-2.0.0
2024-06-11 07:10:09 +02:00
Jérémie Panzer
c6c813a4ee Merge pull request #1395 from Athou/dependabot/maven/io.swagger.core.v3-swagger-maven-plugin-jakarta-2.2.22
Bump io.swagger.core.v3:swagger-maven-plugin-jakarta from 2.2.21 to 2.2.22
2024-06-11 07:10:01 +02:00
Jérémie Panzer
ad5787a38b Merge pull request #1421 from Athou/dependabot/maven/io.github.hakky54-sslcontext-kickstart-for-apache5-8.3.6
Bump io.github.hakky54:sslcontext-kickstart-for-apache5 from 8.3.4 to 8.3.6
2024-06-11 07:09:53 +02:00
Jérémie Panzer
387ceabf30 Merge pull request #1418 from Athou/dependabot/maven/org.apache.maven.plugins-maven-shade-plugin-3.6.0
Bump org.apache.maven.plugins:maven-shade-plugin from 3.5.2 to 3.6.0
2024-06-11 07:09:32 +02:00
Jérémie Panzer
ffe6962c36 Merge pull request #1423 from Athou/dependabot/maven/io.github.git-commit-id-git-commit-id-maven-plugin-9.0.0
Bump io.github.git-commit-id:git-commit-id-maven-plugin from 8.0.2 to 9.0.0
2024-06-11 07:09:24 +02:00
Jérémie Panzer
6d599fc77d Merge pull request #1422 from Athou/dependabot/maven/org.apache.maven.plugins-maven-checkstyle-plugin-3.4.0
Bump org.apache.maven.plugins:maven-checkstyle-plugin from 3.3.1 to 3.4.0
2024-06-11 07:09:01 +02:00
Jérémie Panzer
9fcff1342c Merge pull request #1425 from Athou/dependabot/npm_and_yarn/commafeed-client/braces-3.0.3
Bump braces from 3.0.2 to 3.0.3 in /commafeed-client
2024-06-11 07:08:01 +02:00
dependabot[bot]
f7dbc2e9aa Bump braces from 3.0.2 to 3.0.3 in /commafeed-client
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:03:52 +00:00
Athou
468f2e4c76 remove warnings 2024-06-11 07:02:38 +02:00
Athou
883c9c79aa replace source and target with the new release setting 2024-06-10 12:20:26 +02:00
Athou
f171d05088 querydsl is no longer maintained, use an active fork 2024-06-10 12:17:36 +02:00
dependabot[bot]
f85745fe40 Bump io.github.git-commit-id:git-commit-id-maven-plugin
Bumps [io.github.git-commit-id:git-commit-id-maven-plugin](https://github.com/git-commit-id/git-commit-id-maven-plugin) from 8.0.2 to 9.0.0.
- [Release notes](https://github.com/git-commit-id/git-commit-id-maven-plugin/releases)
- [Commits](https://github.com/git-commit-id/git-commit-id-maven-plugin/compare/v8.0.2...v9.0.0)

---
updated-dependencies:
- dependency-name: io.github.git-commit-id:git-commit-id-maven-plugin
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 09:17:15 +00:00
Athou
5ad93bb3ba add more checkstyle rules 2024-06-10 07:40:35 +02:00
dependabot[bot]
d80ed9d4dd Bump org.apache.maven.plugins:maven-checkstyle-plugin
Bumps [org.apache.maven.plugins:maven-checkstyle-plugin](https://github.com/apache/maven-checkstyle-plugin) from 3.3.1 to 3.4.0.
- [Commits](https://github.com/apache/maven-checkstyle-plugin/compare/maven-checkstyle-plugin-3.3.1...maven-checkstyle-plugin-3.4.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-checkstyle-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-08 20:35:05 +00:00
dependabot[bot]
69b5f5418a Bump io.github.hakky54:sslcontext-kickstart-for-apache5
Bumps [io.github.hakky54:sslcontext-kickstart-for-apache5](https://github.com/Hakky54/sslcontext-kickstart) from 8.3.4 to 8.3.6.
- [Changelog](https://github.com/Hakky54/sslcontext-kickstart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Hakky54/sslcontext-kickstart/compare/v8.3.4...v8.3.6)

---
updated-dependencies:
- dependency-name: io.github.hakky54:sslcontext-kickstart-for-apache5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-08 20:35:01 +00:00
dependabot[bot]
06aa37659c Bump org.apache.maven.plugins:maven-shade-plugin from 3.5.2 to 3.6.0
Bumps [org.apache.maven.plugins:maven-shade-plugin](https://github.com/apache/maven-shade-plugin) from 3.5.2 to 3.6.0.
- [Release notes](https://github.com/apache/maven-shade-plugin/releases)
- [Commits](https://github.com/apache/maven-shade-plugin/compare/maven-shade-plugin-3.5.2...maven-shade-plugin-3.6.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-shade-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-03 09:45:39 +00:00
Jérémie Panzer
d5c98de839 Merge pull request #1360 from Athou/dependabot/maven/org.apache.maven.plugins-maven-jar-plugin-3.4.1
Bump org.apache.maven.plugins:maven-jar-plugin from 3.4.0 to 3.4.1
2024-06-02 10:19:14 +02:00
Jérémie Panzer
920975059c Merge pull request #1393 from Athou/dependabot/maven/org.mariadb.jdbc-mariadb-java-client-3.4.0
Bump org.mariadb.jdbc:mariadb-java-client from 3.3.3 to 3.4.0
2024-06-02 10:18:07 +02:00
Jérémie Panzer
7c6e4c3356 Merge pull request #1397 from Athou/dependabot/maven/redis.clients-jedis-5.1.3
Bump redis.clients:jedis from 5.1.2 to 5.1.3
2024-06-02 10:17:46 +02:00
Jérémie Panzer
143971da5e Merge pull request #1385 from Athou/dependabot/maven/com.mysql-mysql-connector-j-8.4.0
Bump com.mysql:mysql-connector-j from 8.3.0 to 8.4.0
2024-06-02 10:17:32 +02:00
dependabot[bot]
8976e9c01a Bump react-icons from 5.0.1 to 5.2.1 in /commafeed-client
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.0.1 to 5.2.1.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.0.1...v5.2.1)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 07:10:59 +00:00
dependabot[bot]
20c6355efd Bump redis.clients:jedis from 5.1.2 to 5.1.3
Bumps [redis.clients:jedis](https://github.com/redis/jedis) from 5.1.2 to 5.1.3.
- [Release notes](https://github.com/redis/jedis/releases)
- [Commits](https://github.com/redis/jedis/compare/v5.1.2...v5.1.3)

---
updated-dependencies:
- dependency-name: redis.clients:jedis
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 07:06:21 +00:00
Jérémie Panzer
f86f38ef7a Merge pull request #1396 from SConaway/patch-1
login page: don't autocapitalize the username input
2024-05-26 10:14:36 +02:00
Jérémie Panzer
24311df551 Merge pull request #1389 from luckrnx09/luckrnx09-cmd-k-for-macos-users
Maint: Show `Cmd + K` for macOS users
2024-05-26 10:14:10 +02:00
luckrnx09
d02aa78def fix: determine os type from useOs hook 2024-05-25 21:35:10 +08:00
Steven Conaway
b131020f46 login page: don't autocapitalize the username input 2024-05-22 10:37:32 -06:00
dependabot[bot]
4ab82782b0 Bump io.swagger.core.v3:swagger-maven-plugin-jakarta
Bumps io.swagger.core.v3:swagger-maven-plugin-jakarta from 2.2.21 to 2.2.22.

---
updated-dependencies:
- dependency-name: io.swagger.core.v3:swagger-maven-plugin-jakarta
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:56:18 +00:00
dependabot[bot]
6f9ebd5d78 Bump com.google.apis:google-api-services-youtube
Bumps com.google.apis:google-api-services-youtube from v3-rev20240310-2.0.0 to v3-rev20240514-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-youtube
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:56:15 +00:00
dependabot[bot]
7ebbf26369 Bump org.mariadb.jdbc:mariadb-java-client from 3.3.3 to 3.4.0
Bumps [org.mariadb.jdbc:mariadb-java-client](https://github.com/mariadb-corporation/mariadb-connector-j) from 3.3.3 to 3.4.0.
- [Release notes](https://github.com/mariadb-corporation/mariadb-connector-j/releases)
- [Changelog](https://github.com/mariadb-corporation/mariadb-connector-j/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mariadb-corporation/mariadb-connector-j/compare/3.3.3...3.4.0)

---
updated-dependencies:
- dependency-name: org.mariadb.jdbc:mariadb-java-client
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:55:10 +00:00
dependabot[bot]
dbc93f9928 Bump io.swagger.core.v3:swagger-annotations from 2.2.21 to 2.2.22
Bumps io.swagger.core.v3:swagger-annotations from 2.2.21 to 2.2.22.

---
updated-dependencies:
- dependency-name: io.swagger.core.v3:swagger-annotations
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:55:06 +00:00
dependabot[bot]
ad6ebd7e4d Bump com.microsoft.playwright:playwright from 1.43.0 to 1.44.0
Bumps [com.microsoft.playwright:playwright](https://github.com/microsoft/playwright-java) from 1.43.0 to 1.44.0.
- [Release notes](https://github.com/microsoft/playwright-java/releases)
- [Commits](https://github.com/microsoft/playwright-java/compare/v1.43.0...v1.44.0)

---
updated-dependencies:
- dependency-name: com.microsoft.playwright:playwright
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:55:04 +00:00
luckrnx09
ab86247c8c update the hot key to mod+k for call spotlight 2024-05-15 21:47:34 +08:00
luckrnx09
884516be28 Maint: Show Cmd + K for macOS users 2024-05-15 21:42:32 +08:00
dependabot[bot]
c236b1adda Bump com.mysql:mysql-connector-j from 8.3.0 to 8.4.0
Bumps [com.mysql:mysql-connector-j](https://github.com/mysql/mysql-connector-j) from 8.3.0 to 8.4.0.
- [Changelog](https://github.com/mysql/mysql-connector-j/blob/release/8.x/CHANGES)
- [Commits](https://github.com/mysql/mysql-connector-j/compare/8.3.0...8.4.0)

---
updated-dependencies:
- dependency-name: com.mysql:mysql-connector-j
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 09:42:14 +00:00
dependabot[bot]
222117dafe Bump tss-react from 4.9.6 to 4.9.10 in /commafeed-client
Bumps [tss-react](https://github.com/garronej/tss-react) from 4.9.6 to 4.9.10.
- [Release notes](https://github.com/garronej/tss-react/releases)
- [Commits](https://github.com/garronej/tss-react/compare/v4.9.6...v4.9.10)

---
updated-dependencies:
- dependency-name: tss-react
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-01 09:22:50 +00:00
dependabot[bot]
38cd27df57 Bump dayjs from 1.11.10 to 1.11.11 in /commafeed-client
Bumps [dayjs](https://github.com/iamkun/dayjs) from 1.11.10 to 1.11.11.
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.10...v1.11.11)

---
updated-dependencies:
- dependency-name: dayjs
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-29 15:58:08 +00:00
dependabot[bot]
5e07e74bb2 Bump react-dom and @types/react-dom in /commafeed-client
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). These dependencies needed to be updated together.

Updates `react-dom` from 18.2.0 to 18.3.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react-dom)

Updates `@types/react-dom` from 18.2.25 to 18.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/react-dom"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-26 19:04:55 +00:00
dependabot[bot]
fe779e361f Bump @fontsource/open-sans from 5.0.27 to 5.0.28 in /commafeed-client
Bumps [@fontsource/open-sans](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/open-sans) from 5.0.27 to 5.0.28.
- [Changelog](https://github.com/fontsource/font-files/blob/main/fonts/google/open-sans/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/open-sans)

---
updated-dependencies:
- dependency-name: "@fontsource/open-sans"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-26 19:03:47 +00:00
dependabot[bot]
1a51799497 Bump org.apache.maven.plugins:maven-jar-plugin from 3.4.0 to 3.4.1
Bumps [org.apache.maven.plugins:maven-jar-plugin](https://github.com/apache/maven-jar-plugin) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/apache/maven-jar-plugin/releases)
- [Commits](https://github.com/apache/maven-jar-plugin/compare/maven-jar-plugin-3.4.0...maven-jar-plugin-3.4.1)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-jar-plugin
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 09:47:58 +00:00
dependabot[bot]
6ea926cdb0 Bump com.ibm.icu:icu4j from 74.2 to 75.1
Bumps [com.ibm.icu:icu4j](https://github.com/unicode-org/icu) from 74.2 to 75.1.
- [Release notes](https://github.com/unicode-org/icu/releases)
- [Commits](https://github.com/unicode-org/icu/commits)

---
updated-dependencies:
- dependency-name: com.ibm.icu:icu4j
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 09:46:50 +00:00
Jérémie Panzer
439d61946a Merge pull request #1351 from WangLei1993/master
add Chinese translation for new entry
2024-04-16 10:20:52 +02:00
WangLei1993
426c8d7dfb add Chinese translation for new entry 2024-04-16 15:01:23 +08:00
Athou
f1b51e8342 set the default value for new users to the same value as the default value for existing users 2024-04-15 21:59:54 +02:00
142 changed files with 15597 additions and 17503 deletions

View File

@@ -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

View File

@@ -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

Binary file not shown.

View File

@@ -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

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:17-jre FROM eclipse-temurin:21.0.3_9-jre
EXPOSE 8082 EXPOSE 8082

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,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"
}
} }

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)
}

View File

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

View File

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

View File

@@ -1,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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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>}
</> />
) </>
} )
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -1,4 +0,0 @@
html, body {
/* disable pull-to-refresh on mobile as it messes with vertical scrolling */
overscroll-behavior: none;
}

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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