forked from Archives/Athou_commafeed
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9354fb8e18 | ||
|
|
664ed317a0 | ||
|
|
5bf121782b | ||
|
|
66c361e6a6 | ||
|
|
0946c0248e | ||
|
|
a8be8f2edf | ||
|
|
99db85328b | ||
|
|
5f29838bd2 | ||
|
|
7d2c0e7576 | ||
|
|
b8211e69e9 | ||
|
|
d7b2c5a6e3 | ||
|
|
18358d5991 | ||
|
|
e9b4895b0f | ||
|
|
c4fbf98200 | ||
|
|
b0aa6ae524 | ||
|
|
11dd151a3b | ||
|
|
874e7dcee6 | ||
|
|
8297edaf71 | ||
|
|
9e4e629a1a | ||
|
|
8b86617f18 | ||
|
|
bbda35f868 | ||
|
|
df68405fef | ||
|
|
65194d948f | ||
|
|
d49297216c | ||
|
|
e3e50f8456 | ||
|
|
e90b3730ef | ||
|
|
7675a24eb6 | ||
|
|
2bf9186135 | ||
|
|
d4ea51c145 | ||
|
|
6e0e99694e | ||
|
|
9ede8d1c46 | ||
|
|
fd0425a2be | ||
|
|
2b976cadeb | ||
|
|
023c27a565 | ||
|
|
69c9988404 | ||
|
|
b1a4debb95 | ||
|
|
5663d619aa | ||
|
|
2ef9e8d274 | ||
|
|
1292018de0 | ||
|
|
039e91414e | ||
|
|
662d0f754f | ||
|
|
7fb7efbdf7 | ||
|
|
a841c80261 | ||
|
|
da4143fa13 | ||
|
|
789857b09f | ||
|
|
ed45746f52 | ||
|
|
deb51f2ccc | ||
|
|
5fec4a4c5f | ||
|
|
7b335e2fd4 | ||
|
|
60b6c69020 | ||
|
|
08ab32c4c2 | ||
|
|
ff24fe4c7c | ||
|
|
50c62fb468 | ||
|
|
201331afc3 | ||
|
|
cf3100081e | ||
|
|
860aab7495 | ||
|
|
b084c8d108 | ||
|
|
8e0a53fc49 | ||
|
|
4ea2bad083 | ||
|
|
46065d938d | ||
|
|
16389824f7 | ||
|
|
92b624ca8a | ||
|
|
1ae5111f76 | ||
|
|
d9a9a01a60 | ||
|
|
bbbb9c10a6 | ||
|
|
50cf9718a3 | ||
|
|
99a7ede82d | ||
|
|
7b1218ef1e | ||
|
|
8dab16090f | ||
|
|
6e5f362a8e | ||
|
|
96212afd27 | ||
|
|
7e02380858 | ||
|
|
2742b7fff6 | ||
|
|
dade873420 | ||
|
|
e7925e6330 | ||
|
|
f845f225cf | ||
|
|
39ba4a1c97 | ||
|
|
a491b95a02 | ||
|
|
e0c05c8e5d | ||
|
|
2f1aa12e30 | ||
|
|
4c532cf028 | ||
|
|
dc95044fbc | ||
|
|
418cb4797d | ||
|
|
c646503501 | ||
|
|
0ea0db48db | ||
|
|
bb4bb0c7d7 | ||
|
|
97781d5551 | ||
|
|
f4e48383cc | ||
|
|
aa009c366d | ||
|
|
1289dbae84 | ||
|
|
8c69dd355c | ||
|
|
fdf4fdcc87 | ||
|
|
9cd1cde571 | ||
|
|
1b4b3ca52c | ||
|
|
6a76c8b8c3 | ||
|
|
b49d35f181 | ||
|
|
5ba248eaba | ||
|
|
11aff68052 | ||
|
|
07dd10848f | ||
|
|
b2bd386e9c | ||
|
|
d09cabb8c6 | ||
|
|
818d847607 | ||
|
|
1db53e48c6 | ||
|
|
5601d150c3 | ||
|
|
a35f55cde6 | ||
|
|
3714bfaccc | ||
|
|
5541cc9fbe | ||
|
|
bdabd9db0d | ||
|
|
2762c535d6 | ||
|
|
241c465eba | ||
|
|
6c3895e60a | ||
|
|
a30bf18102 | ||
|
|
d9ccdf1caf | ||
|
|
155e7ba1aa | ||
|
|
00faf44c94 | ||
|
|
c45f832131 | ||
|
|
6f781216cd | ||
|
|
fd0e5426e5 | ||
|
|
b5d99b9661 | ||
|
|
50fcdece86 | ||
|
|
d882553644 | ||
|
|
bf71e825a4 | ||
|
|
351701d674 | ||
|
|
cb4a8df0d2 | ||
|
|
7ef865506f | ||
|
|
e4863e8881 | ||
|
|
c86a060170 | ||
|
|
6ed5637e57 | ||
|
|
929df60f09 |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
java: [ "8", "11", "17", "21" ]
|
||||
java: [ "17", "21" ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -34,8 +34,8 @@ jobs:
|
||||
run: mvn --batch-mode --update-snapshots verify
|
||||
|
||||
- name: Upload JAR
|
||||
uses: actions/upload-artifact@v3
|
||||
if: ${{ matrix.java == '8' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.java == '17' }}
|
||||
with:
|
||||
name: commafeed.jar
|
||||
path: commafeed-server/target/commafeed.jar
|
||||
@@ -43,14 +43,14 @@ jobs:
|
||||
# Docker
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v2
|
||||
if: ${{ matrix.java == '8' }}
|
||||
if: ${{ matrix.java == '17' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker build and push tag
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ matrix.java == '8' && github.ref_type == 'tag' }}
|
||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Docker build and push master
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ matrix.java == '8' && github.ref_name == 'master' }}
|
||||
if: ${{ matrix.java == '17' && github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -71,14 +71,14 @@ jobs:
|
||||
# Create GitHub release after Docker image has been published
|
||||
- name: Extract Changelog Entry
|
||||
uses: mindsers/changelog-reader-action@v2
|
||||
if: ${{ matrix.java == '8' && github.ref_type == 'tag' }}
|
||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
||||
id: changelog_reader
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: ${{ matrix.java == '8' && github.ref_type == 'tag' }}
|
||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
||||
with:
|
||||
name: CommaFeed ${{ github.ref_name }}
|
||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,5 +35,8 @@ src/main/app/lib
|
||||
# Sublime
|
||||
*.sublime*
|
||||
|
||||
# VSCode
|
||||
.vscode
|
||||
|
||||
# Macs
|
||||
*.DS_Store
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [4.2.0]
|
||||
|
||||
- add a setting to display the action buttons in the footer instead of in the header on mobile (#1121)
|
||||
- the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API
|
||||
call to get the latest data when receiving the notification
|
||||
- add a workaround to the Fever API for the Unread iOS app (#1188)
|
||||
- fix an issue that caused dates to be saved incorrectly if the database server and the application server were in
|
||||
different timezones (#1187)
|
||||
|
||||
## [4.1.0]
|
||||
|
||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
||||
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
||||
to 365 days
|
||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||
page instead of the welcome page when not logged in (#1185)
|
||||
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
|
||||
- the demo account (if enabled) cannot register custom javascript code anymore
|
||||
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
|
||||
- the openapi documentation is no longer cached by the browser so you always have access to the latest version
|
||||
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
|
||||
with limited memory
|
||||
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
|
||||
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
|
||||
marking all entries as read
|
||||
- your custom sidebar width is now persisted in the local storage of your browser
|
||||
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
|
||||
- added support for youtube playlist favicons
|
||||
- custom JS code is now executed when the app is done loading instead of when the page is loaded
|
||||
- the favicon is now correctly returned for feeds that return an invalid content type
|
||||
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
|
||||
request, reducing CPU usage
|
||||
- updated UI library Mantine to 7.0, improving performance
|
||||
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||
- migrated documentation from swagger 2 to openapi 3
|
||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||
configured (see config.yml.example)
|
||||
- the websocket connection now works correctly when the context root of the application is not "/"
|
||||
- unstable pubsubhubbub support was removed
|
||||
|
||||
## [3.10.1]
|
||||
|
||||
- swap next and previous buttons (#1159)
|
||||
@@ -11,7 +62,8 @@
|
||||
|
||||
## [3.10.0]
|
||||
|
||||
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in Settings -> Profile)
|
||||
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
|
||||
Settings -> Profile)
|
||||
- long entry titles are no longer shortened in the detailed view
|
||||
- added the "s" keyboard shortcut to star/unstar entries
|
||||
- http sessions are now stored in the database (they were stored on disk before)
|
||||
@@ -126,7 +178,8 @@
|
||||
## [3.0.1]
|
||||
|
||||
- allow env variable substitution in config.yml
|
||||
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its value
|
||||
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
|
||||
value
|
||||
- allow env variable prefixed with `CF_` to override config.yml properties
|
||||
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
|
||||
|
||||
|
||||
39
README.md
39
README.md
@@ -54,6 +54,29 @@ user is `admin` and the default password is `admin`.
|
||||
The server will listen on http://localhost:8082. The default
|
||||
user is `admin` and the default password is `admin`.
|
||||
|
||||
### Memory management
|
||||
|
||||
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
|
||||
operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
|
||||
However, this can be problematic on systems with limited memory.
|
||||
|
||||
#### Hard limit
|
||||
|
||||
The JVM can be configured to use a maximum amount of memory with the `-Xmx` parameter.
|
||||
For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
||||
|
||||
#### Dynamic sizing
|
||||
|
||||
The JVM can be configured to release unused memory to the operating system with the following parameters:
|
||||
|
||||
-Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
|
||||
|
||||
This is how the Docker image is configured.
|
||||
See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
|
||||
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
|
||||
more
|
||||
information.
|
||||
|
||||
## Translation
|
||||
|
||||
Files for internationalization are
|
||||
@@ -86,19 +109,3 @@ two-letters [ISO-639-1 language code](http://en.wikipedia.org/wiki/List_of_ISO_6
|
||||
|
||||
The frontend server is now running at http://localhost:8082 and is proxying REST requests to the backend running on
|
||||
port 8083
|
||||
|
||||
## Copyright and license
|
||||
|
||||
Copyright 2013-2023 CommaFeed.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this work except in compliance with the License.
|
||||
You may obtain a copy of the License in the LICENSE file, or at:
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"react-app",
|
||||
"airbnb",
|
||||
"airbnb-typescript",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["@typescript-eslint", "prettier", "hooks"],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
// make eslint check prettier rules
|
||||
"prettier/prettier": "error",
|
||||
|
||||
// enforce consistent curly braces usage
|
||||
"curly": ["error", "multi-line", "consistent"],
|
||||
|
||||
// set "props" to false because it cases false positives with immer
|
||||
"no-param-reassign": ["error", { "props": false }],
|
||||
|
||||
"prefer-destructuring": [
|
||||
"error",
|
||||
{
|
||||
"array": false,
|
||||
"object": true
|
||||
},
|
||||
{
|
||||
"enforceForRenamedProperties": false
|
||||
}
|
||||
],
|
||||
|
||||
// causes issues in thunks when we want to dispatch an action that is defined in the reducer
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
|
||||
// make sure the key prop is filled when required
|
||||
"react/jsx-key": ["error", { "checkFragmentShorthand": true }],
|
||||
|
||||
// configure additional hooks
|
||||
"react-hooks/exhaustive-deps": [
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(^useAsync$|useDidUpdate)"
|
||||
}
|
||||
],
|
||||
|
||||
// trigger even if props is used only in createStyles()
|
||||
"react/no-unused-prop-types": "off",
|
||||
|
||||
// no longer required with modern react versions
|
||||
"react/react-in-jsx-scope": "off",
|
||||
|
||||
// not required with typescript
|
||||
"react/prop-types": "off",
|
||||
"react/require-default-props": "off",
|
||||
|
||||
// matter of taste
|
||||
"react/destructuring-assignment": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
|
||||
// enforce hook call order
|
||||
"hooks/sort": [
|
||||
2,
|
||||
{
|
||||
"groups": [
|
||||
"useLocation",
|
||||
"useParams",
|
||||
"useState",
|
||||
"useAppSelector",
|
||||
"useAppDispatch",
|
||||
"useAsync",
|
||||
"useForm",
|
||||
"useAsyncCallback",
|
||||
"useCallback",
|
||||
"useEffect"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
41
commafeed-client/.eslintrc.cjs
Normal file
41
commafeed-client/.eslintrc.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true
|
||||
},
|
||||
extends: ["standard-with-typescript", "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: {
|
||||
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/strict-boolean-expressions": "off",
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react-hooks/exhaustive-deps": "error"
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
"semi": false,
|
||||
"tabWidth": 4,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
"endOfLine": "auto",
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
9365
commafeed-client/package-lock.json
generated
9365
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,72 +14,72 @@
|
||||
"i18n:extract": "lingui extract --clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@fontsource/open-sans": "^5.0.1",
|
||||
"@lingui/core": "^4.1.2",
|
||||
"@lingui/macro": "^4.1.2",
|
||||
"@lingui/react": "^4.1.2",
|
||||
"@mantine/core": "^6.0.11",
|
||||
"@mantine/form": "^6.0.11",
|
||||
"@mantine/hooks": "^6.0.11",
|
||||
"@mantine/modals": "^6.0.11",
|
||||
"@mantine/notifications": "^6.0.11",
|
||||
"@mantine/spotlight": "^6.0.11",
|
||||
"@mantine/styles": "^6.0.11",
|
||||
"@monaco-editor/react": "^4.5.1",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"axios": "^1.4.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@fontsource/open-sans": "^5.0.20",
|
||||
"@lingui/core": "^4.7.0",
|
||||
"@lingui/macro": "^4.7.0",
|
||||
"@lingui/react": "^4.7.0",
|
||||
"@mantine/core": "^7.3.2",
|
||||
"@mantine/form": "^7.3.2",
|
||||
"@mantine/hooks": "^7.3.2",
|
||||
"@mantine/modals": "^7.3.2",
|
||||
"@mantine/notifications": "^7.3.2",
|
||||
"@mantine/spotlight": "^7.3.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"axios": "^1.6.3",
|
||||
"dayjs": "^1.11.10",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"interweave": "^13.1.0",
|
||||
"monaco-editor": "^0.38.0",
|
||||
"monaco-editor": "^0.45.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"re-resizable": "^6.9.9",
|
||||
"react": "^18.2.0",
|
||||
"react-async-hook": "^4.0.0",
|
||||
"react-contexify": "^6.0.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-swipeable": "^7.0.0",
|
||||
"swagger-ui-react": "^4.18.3",
|
||||
"react-redux": "^9.0.4",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"redoc": "^2.1.3",
|
||||
"throttle-debounce": "^5.0.0",
|
||||
"tinycon": "^0.6.8",
|
||||
"tss-react": "^4.9.3",
|
||||
"use-local-storage": "^3.0.0",
|
||||
"websocket-heartbeat-js": "^1.1.2"
|
||||
"websocket-heartbeat-js": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/cli": "^4.1.2",
|
||||
"@lingui/vite-plugin": "^4.1.2",
|
||||
"@types/eslint": "^8.40.0",
|
||||
"@types/mousetrap": "^1.6.11",
|
||||
"@types/react": "^18.2.6",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/react-infinite-scroller": "^1.2.3",
|
||||
"@types/swagger-ui-react": "^4.18.0",
|
||||
"@types/throttle-debounce": "^5.0.0",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.7",
|
||||
"@typescript-eslint/parser": "^5.59.7",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@lingui/cli": "^4.7.0",
|
||||
"@lingui/vite-plugin": "^4.7.0",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-infinite-scroller": "^1.2.5",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@types/tinycon": "^0.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-hooks": "^0.4.3",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"prettier": "^2.8.8",
|
||||
"rollup-plugin-visualizer": "^5.9.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.3.9",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard-with-typescript": "^43.0.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-n": "^16.5.0",
|
||||
"eslint-plugin-prettier": "^5.1.2",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^3.1.1",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.31.1",
|
||||
"vitest-mock-extended": "^1.1.3"
|
||||
"vite-tsconfig-paths": "^4.2.3",
|
||||
"vitest": "^1.1.3",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>3.10.1</version>
|
||||
<version>4.2.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
@@ -15,7 +15,7 @@
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.12.1</version>
|
||||
<version>1.15.0</version>
|
||||
<?m2e ignore?>
|
||||
<executions>
|
||||
<execution>
|
||||
@@ -25,8 +25,8 @@
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<nodeVersion>v16.16.0</nodeVersion>
|
||||
<npmVersion>8.15.0</npmVersion>
|
||||
<nodeVersion>v20.10.0</nodeVersion>
|
||||
<npmVersion>10.2.5</npmVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
@@ -39,16 +39,6 @@
|
||||
<arguments>ci</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run eslint</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>run eslint</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run test</id>
|
||||
<goals>
|
||||
@@ -73,7 +63,7 @@
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<version>3.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy web interface to resources</id>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { i18n } from "@lingui/core"
|
||||
import { I18nProvider } from "@lingui/react"
|
||||
import { ColorScheme, ColorSchemeProvider, MantineProvider } from "@mantine/core"
|
||||
import { useColorScheme } from "@mantine/hooks"
|
||||
import { MantineProvider } from "@mantine/core"
|
||||
import { useDidUpdate } from "@mantine/hooks"
|
||||
import { ModalsProvider } from "@mantine/modals"
|
||||
import { Notifications } from "@mantine/notifications"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectTo } from "app/slices/redirect"
|
||||
import { reloadServerInfos } from "app/slices/server"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
import { reloadServerInfos } from "app/server/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { categoryUnreadCount } from "app/utils"
|
||||
import { ErrorBoundary } from "components/ErrorBoundary"
|
||||
@@ -29,44 +29,50 @@ import { LoginPage } from "pages/auth/LoginPage"
|
||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
||||
import { WelcomePage } from "pages/WelcomePage"
|
||||
import React, { useEffect } from "react"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import ReactGA from "react-ga4"
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
||||
import Tinycon from "tinycon"
|
||||
import useLocalStorage from "use-local-storage"
|
||||
|
||||
function Providers(props: { children: React.ReactNode }) {
|
||||
const preferredColorScheme = useColorScheme()
|
||||
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>("color-scheme", preferredColorScheme)
|
||||
const toggleColorScheme = (value?: ColorScheme) => setColorScheme(value ?? (colorScheme === "dark" ? "light" : "dark"))
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
primaryColor: "orange",
|
||||
colorScheme,
|
||||
fontFamily: "Open Sans",
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<Notifications position="bottom-right" zIndex={9999} />
|
||||
<ErrorBoundary>{props.children}</ErrorBoundary>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme="auto"
|
||||
theme={{
|
||||
primaryColor: "orange",
|
||||
fontFamily: "Open Sans",
|
||||
colors: {
|
||||
// keep using dark colors from mantine v6
|
||||
// https://v6.mantine.dev/theming/colors/#default-colors
|
||||
dark: [
|
||||
"#C1C2C5",
|
||||
"#A6A7AB",
|
||||
"#909296",
|
||||
"#5c5f66",
|
||||
"#373A40",
|
||||
"#2C2E33",
|
||||
"#25262b",
|
||||
"#1A1B1E",
|
||||
"#141517",
|
||||
"#101113",
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<Notifications position="bottom-right" zIndex={9999} />
|
||||
<ErrorBoundary>{props.children}</ErrorBoundary>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// swagger-ui is very large, load only on-demand
|
||||
const ApiDocumentationPage = React.lazy(() => import("pages/app/ApiDocumentationPage"))
|
||||
const ApiDocumentationPage = React.lazy(async () => await import("pages/app/ApiDocumentationPage"))
|
||||
|
||||
function AppRoutes() {
|
||||
const sidebarWidth = useAppSelector(state => state.tree.sidebarWidth)
|
||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||
|
||||
return (
|
||||
@@ -77,7 +83,7 @@ function AppRoutes() {
|
||||
<Route path="register" element={<RegistrationPage />} />
|
||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||
<Route path="api" element={<ApiDocumentationPage />} />
|
||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarWidth={sidebarVisible ? sidebarWidth : 0} />}>
|
||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
||||
<Route path="category">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||
@@ -160,6 +166,40 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
||||
return null
|
||||
}
|
||||
|
||||
function CustomJs() {
|
||||
const scriptLoaded = useRef(false)
|
||||
|
||||
// useDidUpdate is used instead of useEffect because we want to skip the first render
|
||||
// the first render is the render of react-router, the routes are actually loaded in a second render
|
||||
// we want the script to be executed when the first route is done loading
|
||||
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() {
|
||||
useI18n()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -177,6 +217,8 @@ export function App() {
|
||||
<GoogleAnalyticsHandler />
|
||||
<RedirectHandler />
|
||||
<AppRoutes />
|
||||
<CustomJs />
|
||||
<CustomCss />
|
||||
</HashRouter>
|
||||
</>
|
||||
</Providers>
|
||||
|
||||
7
commafeed-client/src/app/async-thunk.ts
Normal file
7
commafeed-client/src/app/async-thunk.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit"
|
||||
import { type AppDispatch, type RootState } from "app/store"
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState
|
||||
dispatch: AppDispatch
|
||||
}>()
|
||||
@@ -1,40 +1,42 @@
|
||||
import axios from "axios"
|
||||
import {
|
||||
AddCategoryRequest,
|
||||
Category,
|
||||
CategoryModificationRequest,
|
||||
CollapseRequest,
|
||||
Entries,
|
||||
FeedInfo,
|
||||
FeedInfoRequest,
|
||||
FeedModificationRequest,
|
||||
GetEntriesPaginatedRequest,
|
||||
IDRequest,
|
||||
LoginRequest,
|
||||
MarkRequest,
|
||||
Metrics,
|
||||
MultipleMarkRequest,
|
||||
PasswordResetRequest,
|
||||
ProfileModificationRequest,
|
||||
RegistrationRequest,
|
||||
ServerInfo,
|
||||
Settings,
|
||||
StarRequest,
|
||||
SubscribeRequest,
|
||||
Subscription,
|
||||
TagRequest,
|
||||
UserModel,
|
||||
type AddCategoryRequest,
|
||||
type AdminSaveUserRequest,
|
||||
type Category,
|
||||
type CategoryModificationRequest,
|
||||
type CollapseRequest,
|
||||
type Entries,
|
||||
type FeedInfo,
|
||||
type FeedInfoRequest,
|
||||
type FeedModificationRequest,
|
||||
type GetEntriesPaginatedRequest,
|
||||
type IDRequest,
|
||||
type LoginRequest,
|
||||
type MarkRequest,
|
||||
type Metrics,
|
||||
type MultipleMarkRequest,
|
||||
type PasswordResetRequest,
|
||||
type ProfileModificationRequest,
|
||||
type RegistrationRequest,
|
||||
type ServerInfo,
|
||||
type Settings,
|
||||
type StarRequest,
|
||||
type SubscribeRequest,
|
||||
type Subscription,
|
||||
type TagRequest,
|
||||
type UserModel,
|
||||
} from "./types"
|
||||
|
||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const { status, data } = error.response
|
||||
if (
|
||||
(error.response.status === 401 && error.response.data === "Credentials are required to access this resource.") ||
|
||||
(error.response.status === 403 && error.response.data === "You don't have the required role to access this resource.")
|
||||
(status === 401 && data?.message === "Credentials are required to access this resource.") ||
|
||||
(status === 403 && data?.message === "You don't have the required role to access this resource.")
|
||||
) {
|
||||
window.location.hash = "/welcome"
|
||||
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -42,34 +44,34 @@ axiosInstance.interceptors.response.use(
|
||||
|
||||
export const client = {
|
||||
category: {
|
||||
getRoot: () => axiosInstance.get<Category>("category/get"),
|
||||
modify: (req: CategoryModificationRequest) => axiosInstance.post("category/modify", req),
|
||||
collapse: (req: CollapseRequest) => axiosInstance.post("category/collapse", req),
|
||||
getEntries: (req: GetEntriesPaginatedRequest) => axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||
markEntries: (req: MarkRequest) => axiosInstance.post("category/mark", req),
|
||||
add: (req: AddCategoryRequest) => axiosInstance.post("category/add", req),
|
||||
delete: (req: IDRequest) => axiosInstance.post("category/delete", req),
|
||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||
},
|
||||
entry: {
|
||||
mark: (req: MarkRequest) => axiosInstance.post("entry/mark", req),
|
||||
markMultiple: (req: MultipleMarkRequest) => axiosInstance.post("entry/markMultiple", req),
|
||||
star: (req: StarRequest) => axiosInstance.post("entry/star", req),
|
||||
getTags: () => axiosInstance.get<string[]>("entry/tags"),
|
||||
tag: (req: TagRequest) => axiosInstance.post("entry/tag", req),
|
||||
mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req),
|
||||
markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req),
|
||||
star: async (req: StarRequest) => await axiosInstance.post("entry/star", req),
|
||||
getTags: async () => await axiosInstance.get<string[]>("entry/tags"),
|
||||
tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req),
|
||||
},
|
||||
feed: {
|
||||
get: (id: string) => axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||
modify: (req: FeedModificationRequest) => axiosInstance.post("feed/modify", req),
|
||||
getEntries: (req: GetEntriesPaginatedRequest) => axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||
markEntries: (req: MarkRequest) => axiosInstance.post("feed/mark", req),
|
||||
fetchFeed: (req: FeedInfoRequest) => axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||
refreshAll: () => axiosInstance.get("feed/refreshAll"),
|
||||
subscribe: (req: SubscribeRequest) => axiosInstance.post<number>("feed/subscribe", req),
|
||||
unsubscribe: (req: IDRequest) => axiosInstance.post("feed/unsubscribe", req),
|
||||
importOpml: (req: File) => {
|
||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||
subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req),
|
||||
unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req),
|
||||
importOpml: async (req: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", req)
|
||||
return axiosInstance.post("feed/import", formData, {
|
||||
return await axiosInstance.post("feed/import", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
@@ -77,23 +79,23 @@ export const client = {
|
||||
},
|
||||
},
|
||||
user: {
|
||||
login: (req: LoginRequest) => axiosInstance.post("user/login", req),
|
||||
register: (req: RegistrationRequest) => axiosInstance.post("user/register", req),
|
||||
passwordReset: (req: PasswordResetRequest) => axiosInstance.post("user/passwordReset", req),
|
||||
getSettings: () => axiosInstance.get<Settings>("user/settings"),
|
||||
saveSettings: (settings: Settings) => axiosInstance.post("user/settings", settings),
|
||||
getProfile: () => axiosInstance.get<UserModel>("user/profile"),
|
||||
saveProfile: (req: ProfileModificationRequest) => axiosInstance.post("user/profile", req),
|
||||
deleteProfile: () => axiosInstance.post("user/profile/deleteAccount"),
|
||||
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req),
|
||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
||||
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
||||
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
||||
},
|
||||
server: {
|
||||
getServerInfos: () => axiosInstance.get<ServerInfo>("server/get"),
|
||||
getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"),
|
||||
},
|
||||
admin: {
|
||||
getAllUsers: () => axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||
saveUser: (req: UserModel) => axiosInstance.post("admin/user/save", req),
|
||||
deleteUser: (req: IDRequest) => axiosInstance.post("admin/user/delete", req),
|
||||
getMetrics: () => axiosInstance.get<Metrics>("admin/metrics"),
|
||||
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
|
||||
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
||||
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -109,7 +111,7 @@ export const errorToStrings = (err: unknown) => {
|
||||
if (err.response) {
|
||||
const { data } = err.response
|
||||
if (typeof data === "string") strings.push(data)
|
||||
if (typeof data === "object" && data.message) strings.push(data.message)
|
||||
if (typeof data === "object" && data.message) strings.push(data.message as string)
|
||||
if (typeof data === "object" && data.errors) strings = [...strings, ...data.errors]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { DEFAULT_THEME } from "@mantine/core"
|
||||
import { IconType } from "react-icons"
|
||||
import { type IconType } from "react-icons"
|
||||
import { FaAt } from "react-icons/fa"
|
||||
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si"
|
||||
import { Category, Entry, SharingSettings } from "./types"
|
||||
import { type Category, type Entry, type SharingSettings } from "./types"
|
||||
|
||||
const categories: { [key: string]: Category } = {
|
||||
const categories: Record<string, Category> = {
|
||||
all: {
|
||||
id: "all",
|
||||
name: t`All`,
|
||||
@@ -86,13 +85,22 @@ export const Constants = {
|
||||
categories,
|
||||
sharing,
|
||||
layout: {
|
||||
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
|
||||
mobileBreakpoint: 992,
|
||||
mobileBreakpointName: "md",
|
||||
headerHeight: 60,
|
||||
entryMaxWidth: 650,
|
||||
isTopVisible: (div: HTMLElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
|
||||
isBottomVisible: (div: HTMLElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
|
||||
isTopVisible: (div: HTMLElement) => {
|
||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
||||
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
||||
},
|
||||
isBottomVisible: (div: HTMLElement) => {
|
||||
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
|
||||
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
||||
},
|
||||
},
|
||||
dom: {
|
||||
headerId: "header",
|
||||
footerId: "footer",
|
||||
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
||||
entryContextMenuId: (entry: Entry) => entry.id,
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable import/first */
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { reducers } from "app/store"
|
||||
import { Entries, Entry } from "app/types"
|
||||
import { AxiosResponse } from "axios"
|
||||
import { type client } from "app/client"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
|
||||
import { reducers, type RootState } from "app/store"
|
||||
import { type Entries, type Entry } from "app/types"
|
||||
import { type AxiosResponse } from "axios"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { mockReset } from "vitest-mock-extended"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "./entries"
|
||||
|
||||
const mockClient = await vi.hoisted(async () => {
|
||||
const mockModule = await import("vitest-mock-extended")
|
||||
@@ -81,7 +81,7 @@ describe("entries", () => {
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
const promise = store.dispatch(loadMoreEntries())
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("entries", () => {
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
|
||||
@@ -133,7 +133,7 @@ describe("entries", () => {
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
|
||||
134
commafeed-client/src/app/entries/slice.ts
Normal file
134
commafeed-client/src/app/entries/slice.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { Constants } from "app/constants"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "app/entries/thunks"
|
||||
import { type Entry } from "app/types"
|
||||
|
||||
export type EntrySourceType = "category" | "feed" | "tag"
|
||||
|
||||
export interface EntrySource {
|
||||
type: EntrySourceType
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
|
||||
interface EntriesState {
|
||||
/** selected source */
|
||||
source: EntrySource
|
||||
sourceLabel: string
|
||||
sourceWebsiteUrl: string
|
||||
entries: ExpendableEntry[]
|
||||
/** 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
|
||||
*/
|
||||
timestamp?: number
|
||||
selectedEntryId?: string
|
||||
hasMore: boolean
|
||||
loading: boolean
|
||||
search?: string
|
||||
scrollingToEntry: boolean
|
||||
}
|
||||
|
||||
const initialState: EntriesState = {
|
||||
source: {
|
||||
type: "category",
|
||||
id: Constants.categories.all.id,
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
}
|
||||
|
||||
export const entriesSlice = createSlice({
|
||||
name: "entries",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
|
||||
state.selectedEntryId = action.payload.id
|
||||
},
|
||||
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.payload.entry.id)
|
||||
.forEach(e => {
|
||||
e.expanded = action.payload.expanded
|
||||
})
|
||||
},
|
||||
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
||||
state.scrollingToEntry = action.payload
|
||||
},
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.search = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.meta.arg.entry.id)
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))
|
||||
.forEach(e => {
|
||||
e.read = true
|
||||
})
|
||||
})
|
||||
builder.addCase(starEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)
|
||||
.forEach(e => {
|
||||
e.starred = action.meta.arg.starred
|
||||
})
|
||||
})
|
||||
builder.addCase(loadEntries.pending, (state, action) => {
|
||||
state.source = action.meta.arg.source
|
||||
state.entries = []
|
||||
state.timestamp = undefined
|
||||
state.sourceLabel = ""
|
||||
state.sourceWebsiteUrl = ""
|
||||
state.hasMore = true
|
||||
state.selectedEntryId = undefined
|
||||
state.loading = true
|
||||
})
|
||||
builder.addCase(loadMoreEntries.pending, state => {
|
||||
state.loading = true
|
||||
})
|
||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||
state.entries = action.payload.entries
|
||||
state.timestamp = action.payload.timestamp
|
||||
state.sourceLabel = action.payload.name
|
||||
state.sourceWebsiteUrl = action.payload.feedLink
|
||||
state.hasMore = action.payload.hasMore
|
||||
state.loading = false
|
||||
})
|
||||
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
|
||||
})
|
||||
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
|
||||
241
commafeed-client/src/app/entries/thunks.ts
Normal file
241
commafeed-client/src/app/entries/thunks.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { entriesSlice, type EntrySource, type EntrySourceType, setSearch } from "app/entries/slice"
|
||||
import type { RootState } from "app/store"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import type { Entry, MarkRequest, TagRequest } from "app/types"
|
||||
import { reloadTags } from "app/user/thunks"
|
||||
import { scrollToWithCallback } from "app/utils"
|
||||
import { flushSync } from "react-dom"
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||
export const loadEntries = createAppAsyncThunk(
|
||||
"entries/load",
|
||||
async (
|
||||
arg: {
|
||||
source: EntrySource
|
||||
clearSearch: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
|
||||
|
||||
const state = thunkApi.getState()
|
||||
const endpoint = getEndpoint(arg.source.type)
|
||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
|
||||
return result.data
|
||||
}
|
||||
)
|
||||
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { source } = state.entries
|
||||
const offset =
|
||||
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 result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||
return result.data
|
||||
})
|
||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||
order: state.user.settings?.readingOrder,
|
||||
readType: state.user.settings?.readingMode,
|
||||
offset,
|
||||
limit: 50,
|
||||
tag: source.type === "tag" ? source.id : undefined,
|
||||
keywords: state.entries.search,
|
||||
})
|
||||
export const reloadEntries = createAppAsyncThunk("entries/reload", async (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
export const search = createAppAsyncThunk("entries/search", async (arg: string, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(setSearch(arg))
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
export const markEntry = createAppAsyncThunk(
|
||||
"entries/entry/mark",
|
||||
(arg: { entry: Entry; read: boolean }) => {
|
||||
client.entry.mark({
|
||||
id: arg.entry.id,
|
||||
read: arg.read,
|
||||
})
|
||||
},
|
||||
{
|
||||
condition: arg => arg.entry.read !== arg.read,
|
||||
}
|
||||
)
|
||||
export const markMultipleEntries = createAppAsyncThunk(
|
||||
"entries/entry/markMultiple",
|
||||
async (
|
||||
arg: {
|
||||
entries: Entry[]
|
||||
read: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const requests: MarkRequest[] = arg.entries.map(e => ({
|
||||
id: e.id,
|
||||
read: arg.read,
|
||||
}))
|
||||
await client.entry.markMultiple({ requests })
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", async (arg: Entry, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
|
||||
const index = entries.findIndex(e => e.id === arg.id)
|
||||
if (index === -1) return
|
||||
|
||||
thunkApi.dispatch(
|
||||
markMultipleEntries({
|
||||
entries: entries.slice(0, index + 1),
|
||||
read: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
export const markAllEntries = createAppAsyncThunk(
|
||||
"entries/entry/markAll",
|
||||
async (
|
||||
arg: {
|
||||
sourceType: EntrySourceType
|
||||
req: MarkRequest
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
|
||||
await endpoint(arg.req)
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
export const starEntry = createAppAsyncThunk("entries/entry/star", (arg: { entry: Entry; starred: boolean }) => {
|
||||
client.entry.star({
|
||||
id: arg.entry.id,
|
||||
feedId: +arg.entry.feedId,
|
||||
starred: arg.starred,
|
||||
})
|
||||
})
|
||||
export const selectEntry = createAppAsyncThunk(
|
||||
"entries/entry/select",
|
||||
(
|
||||
arg: {
|
||||
entry: Entry
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const entry = state.entries.entries.find(e => e.id === arg.entry.id)
|
||||
if (!entry) return
|
||||
|
||||
// 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
|
||||
flushSync(() => {
|
||||
// mark as read if requested
|
||||
if (arg.markAsRead) {
|
||||
thunkApi.dispatch(markEntry({ entry, read: true }))
|
||||
}
|
||||
|
||||
// set entry as selected
|
||||
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
|
||||
|
||||
// expand if requested
|
||||
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
|
||||
if (previouslySelectedEntry) {
|
||||
thunkApi.dispatch(
|
||||
entriesSlice.actions.setEntryExpanded({
|
||||
entry: previouslySelectedEntry,
|
||||
expanded: false,
|
||||
})
|
||||
)
|
||||
}
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
|
||||
})
|
||||
|
||||
if (arg.scrollToEntry) {
|
||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||
if (entryElement) {
|
||||
const alwaysScrollToEntry = state.user.settings?.alwaysScrollToEntry
|
||||
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
|
||||
if (alwaysScrollToEntry || !entryEntirelyVisible) {
|
||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
||||
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
||||
const offset = (header?.bottom ?? 0) + 3
|
||||
scrollToWithCallback({
|
||||
options: {
|
||||
top: entryElement.offsetTop - offset,
|
||||
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
|
||||
},
|
||||
onScrollEnded,
|
||||
})
|
||||
}
|
||||
|
||||
export const selectPreviousEntry = createAppAsyncThunk(
|
||||
"entries/entry/selectPrevious",
|
||||
(
|
||||
arg: {
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
|
||||
if (previousIndex >= 0) {
|
||||
thunkApi.dispatch(
|
||||
selectEntry({
|
||||
entry: entries[previousIndex],
|
||||
expand: arg.expand,
|
||||
markAsRead: arg.markAsRead,
|
||||
scrollToEntry: arg.scrollToEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
export const selectNextEntry = createAppAsyncThunk(
|
||||
"entries/entry/selectNext",
|
||||
(
|
||||
arg: {
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||
if (nextIndex < entries.length) {
|
||||
thunkApi.dispatch(
|
||||
selectEntry({
|
||||
entry: entries[nextIndex],
|
||||
expand: arg.expand,
|
||||
markAsRead: arg.markAsRead,
|
||||
scrollToEntry: arg.scrollToEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
||||
await client.entry.tag(arg)
|
||||
thunkApi.dispatch(reloadTags())
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirectToCategory } from "app/redirect/thunks"
|
||||
import { store } from "app/store"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { redirectToCategory } from "./redirect"
|
||||
|
||||
describe("redirects", () => {
|
||||
it("redirects to category", async () => {
|
||||
19
commafeed-client/src/app/redirect/slice.ts
Normal file
19
commafeed-client/src/app/redirect/slice.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
|
||||
interface RedirectState {
|
||||
to?: string
|
||||
}
|
||||
|
||||
const initialState: RedirectState = {}
|
||||
|
||||
export const redirectSlice = createSlice({
|
||||
name: "redirect",
|
||||
initialState,
|
||||
reducers: {
|
||||
redirectTo: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.to = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { redirectTo } = redirectSlice.actions
|
||||
45
commafeed-client/src/app/redirect/thunks.ts
Normal file
45
commafeed-client/src/app/redirect/thunks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
|
||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||
export const redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
||||
)
|
||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
||||
|
||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||
const { source } = thunkApi.getState().entries
|
||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||
})
|
||||
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||
)
|
||||
export const redirectToRootCategory = createAppAsyncThunk(
|
||||
"redirect/category/root",
|
||||
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||
)
|
||||
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||
)
|
||||
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||
)
|
||||
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||
)
|
||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||
)
|
||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||
)
|
||||
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||
)
|
||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PayloadAction, createAsyncThunk, createSlice } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { ServerInfo } from "app/types"
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { reloadServerInfos } from "app/server/thunks"
|
||||
import { type ServerInfo } from "app/types"
|
||||
|
||||
interface ServerState {
|
||||
serverInfos?: ServerInfo
|
||||
@@ -11,7 +11,6 @@ const initialState: ServerState = {
|
||||
webSocketConnected: false,
|
||||
}
|
||||
|
||||
export const reloadServerInfos = createAsyncThunk("server/infos", () => client.server.getServerInfos().then(r => r.data))
|
||||
export const serverSlice = createSlice({
|
||||
name: "server",
|
||||
initialState,
|
||||
@@ -28,4 +27,3 @@ export const serverSlice = createSlice({
|
||||
})
|
||||
|
||||
export const { setWebSocketConnected } = serverSlice.actions
|
||||
export default serverSlice.reducer
|
||||
4
commafeed-client/src/app/server/thunks.ts
Normal file
4
commafeed-client/src/app/server/thunks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
|
||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
||||
@@ -1,365 +0,0 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { RootState } from "app/store"
|
||||
import { Entries, Entry, MarkRequest, TagRequest } from "app/types"
|
||||
import { scrollToWithCallback } from "app/utils"
|
||||
import { flushSync } from "react-dom"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { reloadTree } from "./tree"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { reloadTags } from "./user"
|
||||
|
||||
export type EntrySourceType = "category" | "feed" | "tag"
|
||||
export type EntrySource = { type: EntrySourceType; id: string }
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
|
||||
interface EntriesState {
|
||||
/** selected source */
|
||||
source: EntrySource
|
||||
sourceLabel: string
|
||||
sourceWebsiteUrl: string
|
||||
entries: ExpendableEntry[]
|
||||
/** 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
|
||||
*/
|
||||
timestamp?: number
|
||||
selectedEntryId?: string
|
||||
hasMore: boolean
|
||||
loading: boolean
|
||||
search?: string
|
||||
scrollingToEntry: boolean
|
||||
}
|
||||
|
||||
const initialState: EntriesState = {
|
||||
source: {
|
||||
type: "category",
|
||||
id: Constants.categories.all.id,
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
}
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||
export const loadEntries = createAsyncThunk<
|
||||
Entries,
|
||||
{ source: EntrySource; clearSearch: boolean },
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("entries/load", async (arg, thunkApi) => {
|
||||
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
|
||||
|
||||
const state = thunkApi.getState()
|
||||
const endpoint = getEndpoint(arg.source.type)
|
||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
|
||||
return result.data
|
||||
})
|
||||
export const loadMoreEntries = createAsyncThunk<
|
||||
Entries,
|
||||
void,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("entries/loadMore", async (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { source } = state.entries
|
||||
const offset =
|
||||
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 result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||
return result.data
|
||||
})
|
||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||
order: state.user.settings?.readingOrder,
|
||||
readType: state.user.settings?.readingMode,
|
||||
offset,
|
||||
limit: 50,
|
||||
tag: source.type === "tag" ? source.id : undefined,
|
||||
keywords: state.entries.search,
|
||||
})
|
||||
export const reloadEntries = createAsyncThunk<
|
||||
void,
|
||||
void,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("entries/reload", async (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
export const search = createAsyncThunk<void, string, { state: RootState }>("entries/search", async (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(setSearch(arg))
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
export const markEntry = createAsyncThunk(
|
||||
"entries/entry/mark",
|
||||
(arg: { entry: Entry; read: boolean }) => {
|
||||
client.entry.mark({
|
||||
id: arg.entry.id,
|
||||
read: arg.read,
|
||||
})
|
||||
},
|
||||
{
|
||||
condition: arg => arg.entry.read !== arg.read,
|
||||
}
|
||||
)
|
||||
export const markMultipleEntries = createAsyncThunk(
|
||||
"entries/entry/markMultiple",
|
||||
async (arg: { entries: Entry[]; read: boolean }, thunkApi) => {
|
||||
const requests: MarkRequest[] = arg.entries.map(e => ({
|
||||
id: e.id,
|
||||
read: arg.read,
|
||||
}))
|
||||
await client.entry.markMultiple({ requests })
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
export const markEntriesUpToEntry = createAsyncThunk<void, Entry, { state: RootState }>(
|
||||
"entries/entry/upToEntry",
|
||||
async (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
|
||||
const index = entries.findIndex(e => e.id === arg.id)
|
||||
if (index === -1) return
|
||||
|
||||
thunkApi.dispatch(
|
||||
markMultipleEntries({
|
||||
entries: entries.slice(0, index + 1),
|
||||
read: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
export const markAllEntries = createAsyncThunk<
|
||||
void,
|
||||
{ sourceType: EntrySourceType; req: MarkRequest },
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("entries/entry/markAll", async (arg, thunkApi) => {
|
||||
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
|
||||
await endpoint(arg.req)
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
thunkApi.dispatch(reloadTree())
|
||||
})
|
||||
export const starEntry = createAsyncThunk("entries/entry/star", (arg: { entry: Entry; starred: boolean }) => {
|
||||
client.entry.star({
|
||||
id: arg.entry.id,
|
||||
feedId: +arg.entry.feedId,
|
||||
starred: arg.starred,
|
||||
})
|
||||
})
|
||||
export const selectEntry = createAsyncThunk<
|
||||
void,
|
||||
{
|
||||
entry: Entry
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
{ state: RootState }
|
||||
>("entries/entry/select", (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const entry = state.entries.entries.find(e => e.id === arg.entry.id)
|
||||
if (!entry) return
|
||||
|
||||
// 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
|
||||
flushSync(() => {
|
||||
// mark as read if requested
|
||||
if (arg.markAsRead) {
|
||||
thunkApi.dispatch(markEntry({ entry, read: true }))
|
||||
}
|
||||
|
||||
// set entry as selected
|
||||
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
|
||||
|
||||
// expand if requested
|
||||
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
|
||||
if (previouslySelectedEntry) {
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false }))
|
||||
}
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
|
||||
})
|
||||
|
||||
if (arg.scrollToEntry) {
|
||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||
if (entryElement) {
|
||||
const alwaysScrollToEntry = state.user.settings?.alwaysScrollToEntry
|
||||
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
|
||||
if (alwaysScrollToEntry || !entryEntirelyVisible) {
|
||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
||||
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||
scrollToWithCallback({
|
||||
options: {
|
||||
// add a small gap between the top of the content and the top of the page
|
||||
top: entryElement.offsetTop - Constants.layout.headerHeight - 3,
|
||||
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
|
||||
},
|
||||
onScrollEnded,
|
||||
})
|
||||
}
|
||||
|
||||
export const selectPreviousEntry = createAsyncThunk<
|
||||
void,
|
||||
{
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
{ state: RootState }
|
||||
>("entries/entry/selectPrevious", (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
|
||||
if (previousIndex >= 0) {
|
||||
thunkApi.dispatch(
|
||||
selectEntry({
|
||||
entry: entries[previousIndex],
|
||||
expand: arg.expand,
|
||||
markAsRead: arg.markAsRead,
|
||||
scrollToEntry: arg.scrollToEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
export const selectNextEntry = createAsyncThunk<
|
||||
void,
|
||||
{
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
{ state: RootState }
|
||||
>("entries/entry/selectNext", (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||
if (nextIndex < entries.length) {
|
||||
thunkApi.dispatch(
|
||||
selectEntry({
|
||||
entry: entries[nextIndex],
|
||||
expand: arg.expand,
|
||||
markAsRead: arg.markAsRead,
|
||||
scrollToEntry: arg.scrollToEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
export const tagEntry = createAsyncThunk<
|
||||
void,
|
||||
TagRequest,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("entries/entry/tag", async (arg, thunkApi) => {
|
||||
await client.entry.tag(arg)
|
||||
thunkApi.dispatch(reloadTags())
|
||||
})
|
||||
|
||||
export const entriesSlice = createSlice({
|
||||
name: "entries",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
|
||||
state.selectedEntryId = action.payload.id
|
||||
},
|
||||
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.payload.entry.id)
|
||||
.forEach(e => {
|
||||
e.expanded = action.payload.expanded
|
||||
})
|
||||
},
|
||||
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
||||
state.scrollingToEntry = action.payload
|
||||
},
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.search = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.meta.arg.entry.id)
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))
|
||||
.forEach(e => {
|
||||
e.read = true
|
||||
})
|
||||
})
|
||||
builder.addCase(starEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)
|
||||
.forEach(e => {
|
||||
e.starred = action.meta.arg.starred
|
||||
})
|
||||
})
|
||||
builder.addCase(loadEntries.pending, (state, action) => {
|
||||
state.source = action.meta.arg.source
|
||||
state.entries = []
|
||||
state.timestamp = undefined
|
||||
state.sourceLabel = ""
|
||||
state.sourceWebsiteUrl = ""
|
||||
state.hasMore = true
|
||||
state.selectedEntryId = undefined
|
||||
state.loading = true
|
||||
})
|
||||
builder.addCase(loadMoreEntries.pending, state => {
|
||||
state.loading = true
|
||||
})
|
||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||
state.entries = action.payload.entries
|
||||
state.timestamp = action.payload.timestamp
|
||||
state.sourceLabel = action.payload.name
|
||||
state.sourceWebsiteUrl = action.payload.feedLink
|
||||
state.hasMore = action.payload.hasMore
|
||||
state.loading = false
|
||||
})
|
||||
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
|
||||
})
|
||||
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
|
||||
export default entriesSlice.reducer
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
import { Constants } from "app/constants"
|
||||
import { RootState } from "app/store"
|
||||
|
||||
interface RedirectState {
|
||||
to?: string
|
||||
}
|
||||
|
||||
const initialState: RedirectState = {}
|
||||
|
||||
export const redirectToLogin = createAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
||||
export const redirectToRegistration = createAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||
export const redirectToPasswordRecovery = createAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
||||
)
|
||||
export const redirectToApiDocumentation = createAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
||||
|
||||
export const redirectToSelectedSource = createAsyncThunk<
|
||||
void,
|
||||
void,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("redirect/selectedSource", (_, thunkApi) => {
|
||||
const { source } = thunkApi.getState().entries
|
||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||
})
|
||||
export const redirectToCategory = createAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||
)
|
||||
export const redirectToRootCategory = createAsyncThunk("redirect/category/root", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||
)
|
||||
export const redirectToCategoryDetails = createAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||
)
|
||||
export const redirectToFeed = createAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||
)
|
||||
export const redirectToFeedDetails = createAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||
)
|
||||
export const redirectToTag = createAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||
export const redirectToTagDetails = createAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||
)
|
||||
export const redirectToAdd = createAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
||||
export const redirectToSettings = createAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
||||
export const redirectToAdminUsers = createAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||
)
|
||||
export const redirectToMetrics = createAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||
)
|
||||
export const redirectToDonate = createAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
||||
export const redirectToAbout = createAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
||||
|
||||
export const redirectSlice = createSlice({
|
||||
name: "redirect",
|
||||
initialState,
|
||||
reducers: {
|
||||
redirectTo: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.to = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { redirectTo } = redirectSlice.actions
|
||||
export default redirectSlice.reducer
|
||||
@@ -1,209 +0,0 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { RootState } from "app/store"
|
||||
import { ReadingMode, ReadingOrder, Settings, SharingSettings, UserModel } from "app/types"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { reloadEntries } from "./entries"
|
||||
|
||||
interface UserState {
|
||||
settings?: Settings
|
||||
profile?: UserModel
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const initialState: UserState = {}
|
||||
|
||||
export const reloadSettings = createAsyncThunk("settings/reload", () => client.user.getSettings().then(r => r.data))
|
||||
export const reloadProfile = createAsyncThunk("profile/reload", () => client.user.getProfile().then(r => r.data))
|
||||
export const reloadTags = createAsyncThunk("entries/tags", () => client.entry.getTags().then(r => r.data))
|
||||
export const changeReadingMode = createAsyncThunk<void, ReadingMode, { state: RootState }>(
|
||||
"settings/readingMode",
|
||||
(readingMode, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingMode })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
}
|
||||
)
|
||||
export const changeReadingOrder = createAsyncThunk<void, ReadingOrder, { state: RootState }>(
|
||||
"settings/readingOrder",
|
||||
(readingOrder, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingOrder })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
}
|
||||
)
|
||||
export const changeLanguage = createAsyncThunk<
|
||||
void,
|
||||
string,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/language", (language, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, language })
|
||||
})
|
||||
export const changeScrollSpeed = createAsyncThunk<
|
||||
void,
|
||||
boolean,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/scrollSpeed", (speed, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||
})
|
||||
export const changeShowRead = createAsyncThunk<
|
||||
void,
|
||||
boolean,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/showRead", (showRead, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, showRead })
|
||||
})
|
||||
export const changeScrollMarks = createAsyncThunk<
|
||||
void,
|
||||
boolean,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/scrollMarks", (scrollMarks, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollMarks })
|
||||
})
|
||||
export const changeAlwaysScrollToEntry = createAsyncThunk<
|
||||
void,
|
||||
boolean,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/alwaysScrollToEntry", (alwaysScrollToEntry, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, alwaysScrollToEntry })
|
||||
})
|
||||
export const changeMarkAllAsReadConfirmation = createAsyncThunk<
|
||||
void,
|
||||
boolean,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/markAllAsReadConfirmation", (markAllAsReadConfirmation, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
||||
})
|
||||
export const changeCustomContextMenu = createAsyncThunk<
|
||||
void,
|
||||
boolean,
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/customContextMenu", (customContextMenu, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, customContextMenu })
|
||||
})
|
||||
export const changeSharingSetting = createAsyncThunk<
|
||||
void,
|
||||
{ site: keyof SharingSettings; value: boolean },
|
||||
{
|
||||
state: RootState
|
||||
}
|
||||
>("settings/sharingSetting", (sharingSetting, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({
|
||||
...settings,
|
||||
sharingSettings: {
|
||||
...settings.sharingSettings,
|
||||
[sharingSetting.site]: sharingSetting.value,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||
state.settings = action.payload
|
||||
})
|
||||
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
||||
state.profile = action.payload
|
||||
})
|
||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||
state.tags = action.payload
|
||||
})
|
||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingMode = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingOrder = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.language = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
||||
})
|
||||
builder.addCase(changeShowRead.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.showRead = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollMarks = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeAlwaysScrollToEntry.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.alwaysScrollToEntry = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.customContextMenu = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
||||
})
|
||||
builder.addMatcher(
|
||||
isAnyOf(
|
||||
changeLanguage.fulfilled,
|
||||
changeScrollSpeed.fulfilled,
|
||||
changeShowRead.fulfilled,
|
||||
changeScrollMarks.fulfilled,
|
||||
changeAlwaysScrollToEntry.fulfilled,
|
||||
changeMarkAllAsReadConfirmation.fulfilled,
|
||||
changeCustomContextMenu.fulfilled,
|
||||
changeSharingSetting.fulfilled
|
||||
),
|
||||
() => {
|
||||
showNotification({
|
||||
message: t`Settings saved.`,
|
||||
color: "green",
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export default userSlice.reducer
|
||||
@@ -1,24 +1,21 @@
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import { setupListeners } from "@reduxjs/toolkit/query"
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||
import entriesReducer from "./slices/entries"
|
||||
import redirectReducer from "./slices/redirect"
|
||||
import serverReducer from "./slices/server"
|
||||
import treeReducer from "./slices/tree"
|
||||
import userReducer from "./slices/user"
|
||||
import { entriesSlice } from "app/entries/slice"
|
||||
import { redirectSlice } from "app/redirect/slice"
|
||||
import { serverSlice } from "app/server/slice"
|
||||
import { treeSlice } from "app/tree/slice"
|
||||
import { userSlice } from "app/user/slice"
|
||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||
|
||||
export const reducers = {
|
||||
entries: entriesReducer,
|
||||
redirect: redirectReducer,
|
||||
tree: treeReducer,
|
||||
server: serverReducer,
|
||||
user: userReducer,
|
||||
entries: entriesSlice.reducer,
|
||||
redirect: redirectSlice.reducer,
|
||||
tree: treeSlice.reducer,
|
||||
server: serverSlice.reducer,
|
||||
user: userSlice.reducer,
|
||||
}
|
||||
|
||||
export const store = configureStore({ reducer: reducers })
|
||||
|
||||
setupListeners(store.dispatch)
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
import { client } from "app/client"
|
||||
import { Category, CollapseRequest } from "app/types"
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { markEntry } from "app/entries/thunks"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
import { collapseTreeCategory, reloadTree } from "app/tree/thunks"
|
||||
import { type Category } from "app/types"
|
||||
import { visitCategoryTree } from "app/utils"
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { markEntry } from "./entries"
|
||||
import { redirectTo } from "./redirect"
|
||||
|
||||
interface TreeState {
|
||||
rootCategory?: Category
|
||||
mobileMenuOpen: boolean
|
||||
sidebarWidth: number
|
||||
sidebarVisible: boolean
|
||||
}
|
||||
|
||||
const initialState: TreeState = {
|
||||
mobileMenuOpen: false,
|
||||
sidebarWidth: 350,
|
||||
sidebarVisible: true,
|
||||
}
|
||||
|
||||
export const reloadTree = createAsyncThunk("tree/reload", () => client.category.getRoot().then(r => r.data))
|
||||
export const collapseTreeCategory = createAsyncThunk("tree/category/collapse", async (req: CollapseRequest) =>
|
||||
client.category.collapse(req)
|
||||
)
|
||||
|
||||
export const treeSlice = createSlice({
|
||||
name: "tree",
|
||||
initialState,
|
||||
@@ -31,12 +23,25 @@ export const treeSlice = createSlice({
|
||||
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.mobileMenuOpen = action.payload
|
||||
},
|
||||
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||
state.sidebarWidth = action.payload
|
||||
},
|
||||
toggleSidebar: state => {
|
||||
state.sidebarVisible = !state.sidebarVisible
|
||||
},
|
||||
incrementUnreadCount: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
feedId: number
|
||||
amount: number
|
||||
}>
|
||||
) => {
|
||||
if (!state.rootCategory) return
|
||||
visitCategoryTree(state.rootCategory, c =>
|
||||
c.feeds
|
||||
.filter(f => f.id === action.payload.feedId)
|
||||
.forEach(f => {
|
||||
f.unread += action.payload.amount
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||
@@ -64,5 +69,4 @@ export const treeSlice = createSlice({
|
||||
},
|
||||
})
|
||||
|
||||
export const { setMobileMenuOpen, setSidebarWidth, toggleSidebar } = treeSlice.actions
|
||||
export default treeSlice.reducer
|
||||
export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions
|
||||
9
commafeed-client/src/app/tree/thunks.ts
Normal file
9
commafeed-client/src/app/tree/thunks.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import type { CollapseRequest } from "app/types"
|
||||
|
||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
||||
export const collapseTreeCategory = createAppAsyncThunk(
|
||||
"tree/category/collapse",
|
||||
async (req: CollapseRequest) => await client.category.collapse(req)
|
||||
)
|
||||
@@ -113,6 +113,7 @@ export interface MarkRequest {
|
||||
id: string
|
||||
read: boolean
|
||||
olderThan?: number
|
||||
insertedBefore?: number
|
||||
keywords?: string
|
||||
excludedSubscriptions?: number[]
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export interface MetricMeter {
|
||||
units: string
|
||||
}
|
||||
|
||||
export type MetricTimer = {
|
||||
export interface MetricTimer {
|
||||
count: number
|
||||
max: number
|
||||
mean: number
|
||||
@@ -155,10 +156,10 @@ export type MetricTimer = {
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
counters: { [key: string]: MetricCounter }
|
||||
gauges: { [key: string]: MetricGauge }
|
||||
meters: { [key: string]: MetricMeter }
|
||||
timers: { [key: string]: MetricTimer }
|
||||
counters: Record<string, MetricCounter>
|
||||
gauges: Record<string, MetricGauge>
|
||||
meters: Record<string, MetricMeter>
|
||||
timers: Record<string, MetricTimer>
|
||||
}
|
||||
|
||||
export interface MultipleMarkRequest {
|
||||
@@ -190,6 +191,9 @@ export interface ServerInfo {
|
||||
googleAnalyticsCode?: string
|
||||
smtpEnabled: boolean
|
||||
demoAccountEnabled: boolean
|
||||
websocketEnabled: boolean
|
||||
websocketPingInterval: number
|
||||
treeReloadInterval: number
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -204,6 +208,7 @@ export interface Settings {
|
||||
alwaysScrollToEntry: boolean
|
||||
markAllAsReadConfirmation: boolean
|
||||
customContextMenu: boolean
|
||||
mobileFooter: boolean
|
||||
sharingSettings: SharingSettings
|
||||
}
|
||||
|
||||
@@ -270,6 +275,15 @@ export interface UserModel {
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export interface AdminSaveUserRequest {
|
||||
id?: number
|
||||
name: string
|
||||
email?: string
|
||||
password?: string
|
||||
enabled: boolean
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export type ReadingMode = "all" | "unread"
|
||||
|
||||
export type ReadingOrder = "asc" | "desc"
|
||||
|
||||
108
commafeed-client/src/app/user/slice.ts
Normal file
108
commafeed-client/src/app/user/slice.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { createSlice, isAnyOf } from "@reduxjs/toolkit"
|
||||
import { type Settings, type UserModel } from "app/types"
|
||||
import {
|
||||
changeAlwaysScrollToEntry,
|
||||
changeCustomContextMenu,
|
||||
changeLanguage,
|
||||
changeMarkAllAsReadConfirmation,
|
||||
changeMobileFooter,
|
||||
changeReadingMode,
|
||||
changeReadingOrder,
|
||||
changeScrollMarks,
|
||||
changeScrollSpeed,
|
||||
changeSharingSetting,
|
||||
changeShowRead,
|
||||
reloadProfile,
|
||||
reloadSettings,
|
||||
reloadTags,
|
||||
} from "./thunks"
|
||||
|
||||
interface UserState {
|
||||
settings?: Settings
|
||||
profile?: UserModel
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const initialState: UserState = {}
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||
state.settings = action.payload
|
||||
})
|
||||
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
||||
state.profile = action.payload
|
||||
})
|
||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||
state.tags = action.payload
|
||||
})
|
||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingMode = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingOrder = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.language = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
||||
})
|
||||
builder.addCase(changeShowRead.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.showRead = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollMarks = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeAlwaysScrollToEntry.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.alwaysScrollToEntry = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.customContextMenu = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeMobileFooter.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.mobileFooter = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
||||
})
|
||||
builder.addMatcher(
|
||||
isAnyOf(
|
||||
changeLanguage.fulfilled,
|
||||
changeScrollSpeed.fulfilled,
|
||||
changeShowRead.fulfilled,
|
||||
changeScrollMarks.fulfilled,
|
||||
changeAlwaysScrollToEntry.fulfilled,
|
||||
changeMarkAllAsReadConfirmation.fulfilled,
|
||||
changeCustomContextMenu.fulfilled,
|
||||
changeMobileFooter.fulfilled,
|
||||
changeSharingSetting.fulfilled
|
||||
),
|
||||
() => {
|
||||
showNotification({
|
||||
message: t`Settings saved.`,
|
||||
color: "green",
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
83
commafeed-client/src/app/user/thunks.ts
Normal file
83
commafeed-client/src/app/user/thunks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { reloadEntries } from "app/entries/thunks"
|
||||
import type { ReadingMode, ReadingOrder, SharingSettings } from "app/types"
|
||||
|
||||
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 reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
||||
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingMode })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
})
|
||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingOrder })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
})
|
||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, language })
|
||||
})
|
||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||
})
|
||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, showRead })
|
||||
})
|
||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollMarks })
|
||||
})
|
||||
export const changeAlwaysScrollToEntry = createAppAsyncThunk("settings/alwaysScrollToEntry", (alwaysScrollToEntry: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, alwaysScrollToEntry })
|
||||
})
|
||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||
"settings/markAllAsReadConfirmation",
|
||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
||||
}
|
||||
)
|
||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, customContextMenu })
|
||||
})
|
||||
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, mobileFooter })
|
||||
})
|
||||
export const changeSharingSetting = createAppAsyncThunk(
|
||||
"settings/sharingSetting",
|
||||
(
|
||||
sharingSetting: {
|
||||
site: keyof SharingSettings
|
||||
value: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({
|
||||
...settings,
|
||||
sharingSettings: {
|
||||
...settings.sharingSettings,
|
||||
[sharingSetting.site]: sharingSetting.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { throttle } from "throttle-debounce"
|
||||
import { Category } from "./types"
|
||||
import { type Category } from "./types"
|
||||
|
||||
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
|
||||
visitor(category)
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd">
|
||||
<sodipodi:namedview guidetolerance="10">
|
||||
<sodipodi:guide position="154.17325,117.15254" orientation="0,1"/>
|
||||
<sodipodi:guide position="154.17325,166.44575" orientation="0,1"/>
|
||||
<sodipodi:guide position="154.17325,288.40369" orientation="0,1"/>
|
||||
<sodipodi:guide position="380.44742,392.71992" orientation="0,1"/>
|
||||
<sodipodi:guide position="101.9661,166.44575" orientation="1,0"/>
|
||||
<sodipodi:guide position="276.13119,288.40369" orientation="1,0"/>
|
||||
<sodipodi:guide position="380.44742,165.67871" orientation="1,0"/>
|
||||
<sodipodi:guide position="154.17325,288.40369" orientation="1,0"/>
|
||||
<sodipodi:guide position="154.17325,166.44575" orientation="-0.70710678,0.70710678"/>
|
||||
<sodipodi:guide position="123.21968,135.49218" orientation="1,0"/>
|
||||
<sodipodi:guide position="123.21968,135.49218" orientation="0,1"/>
|
||||
</sodipodi:namedview>
|
||||
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625"/>
|
||||
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round" stroke-width="0.78125"/>
|
||||
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round" stroke-width="0.78125"/>
|
||||
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z" fill="#FFF"/>
|
||||
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625" />
|
||||
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||
stroke-width="0.78125" />
|
||||
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||
stroke-width="0.78125" />
|
||||
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z"
|
||||
fill="#FFF" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 791 B |
@@ -1,15 +1,14 @@
|
||||
import { ActionIcon, Button, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import { ActionIconProps } from "@mantine/core/lib/ActionIcon/ActionIcon"
|
||||
import { ButtonProps } from "@mantine/core/lib/Button/Button"
|
||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import { type ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
import { forwardRef, MouseEventHandler, ReactNode } from "react"
|
||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||
|
||||
interface ActionButtonProps {
|
||||
className?: string
|
||||
icon?: ReactNode
|
||||
label: ReactNode
|
||||
onClick?: MouseEventHandler
|
||||
variant?: ActionIconProps["variant"] & ButtonProps["variant"]
|
||||
variant?: ActionIconVariant & ButtonVariant
|
||||
hideLabelOnDesktop?: boolean
|
||||
showLabelOnMobile?: boolean
|
||||
}
|
||||
@@ -29,7 +28,7 @@ export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((pr
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button ref={ref} variant={variant} size="xs" className={props.className} leftIcon={props.icon} onClick={props.onClick}>
|
||||
<Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}>
|
||||
{props.label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Box, Alert as MantineAlert } from "@mantine/core"
|
||||
import { Alert as MantineAlert, Box } from "@mantine/core"
|
||||
import { Fragment } from "react"
|
||||
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
|
||||
|
||||
type Level = "error" | "warning" | "success"
|
||||
|
||||
export interface ErrorsAlertProps {
|
||||
level?: Level
|
||||
messages: string[]
|
||||
@@ -31,8 +32,6 @@ export function Alert(props: ErrorsAlertProps) {
|
||||
color = "green"
|
||||
icon = <TbCircleCheck />
|
||||
break
|
||||
default:
|
||||
throw Error(`unsupported level: ${level}`)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AnnouncementDialog() {
|
||||
return (
|
||||
<Dialog opened={opened} withCloseButton onClose={onClosed} size="xl" radius="md">
|
||||
<Box>
|
||||
<Text weight="bold">
|
||||
<Text fw="bold">
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ErrorPage } from "pages/ErrorPage"
|
||||
import React, { ReactNode } from "react"
|
||||
import React, { type ReactNode } from "react"
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children?: ReactNode
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Box, Center, createStyles } from "@mantine/core"
|
||||
import { Box, Center, type MantineTheme, useMantineTheme } from "@mantine/core"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useState } from "react"
|
||||
import { TbPhoto } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
|
||||
interface ImageWithPlaceholderWhileLoadingProps {
|
||||
src: string
|
||||
@@ -12,21 +14,47 @@ interface ImageWithPlaceholderWhileLoadingProps {
|
||||
placeholderHeight?: number
|
||||
placeholderBackgroundColor?: string
|
||||
placeholderIconSize?: number
|
||||
placeholderIconColor?: string
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, props: ImageWithPlaceholderWhileLoadingProps) => ({
|
||||
placeholder: {
|
||||
width: props.placeholderWidth ?? 400,
|
||||
height: props.placeholderHeight ?? 600,
|
||||
maxWidth: "100%",
|
||||
color: props.placeholderIconColor ?? theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
|
||||
backgroundColor: props.placeholderBackgroundColor ?? (theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1]),
|
||||
},
|
||||
}))
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
placeholderWidth?: number
|
||||
placeholderHeight?: number
|
||||
placeholderBackgroundColor?: string
|
||||
}>()
|
||||
.create(props => ({
|
||||
placeholder: {
|
||||
width: props.placeholderWidth ?? 400,
|
||||
height: props.placeholderHeight ?? 600,
|
||||
maxWidth: "100%",
|
||||
backgroundColor:
|
||||
props.placeholderBackgroundColor ??
|
||||
(props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]),
|
||||
},
|
||||
}))
|
||||
|
||||
export function ImageWithPlaceholderWhileLoading(props: ImageWithPlaceholderWhileLoadingProps) {
|
||||
const { classes } = useStyles(props)
|
||||
export function ImageWithPlaceholderWhileLoading({
|
||||
alt,
|
||||
height,
|
||||
placeholderBackgroundColor,
|
||||
placeholderHeight,
|
||||
placeholderIconSize,
|
||||
placeholderWidth,
|
||||
src,
|
||||
title,
|
||||
width,
|
||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
placeholderWidth,
|
||||
placeholderHeight,
|
||||
placeholderBackgroundColor,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
return (
|
||||
@@ -35,17 +63,17 @@ export function ImageWithPlaceholderWhileLoading(props: ImageWithPlaceholderWhil
|
||||
<Box>
|
||||
<Center className={classes.placeholder}>
|
||||
<div>
|
||||
<TbPhoto size={props.placeholderIconSize ?? 48} />
|
||||
<TbPhoto size={placeholderIconSize ?? 48} />
|
||||
</div>
|
||||
</Center>
|
||||
</Box>
|
||||
)}
|
||||
<img
|
||||
src={props.src}
|
||||
alt={props.alt}
|
||||
title={props.title}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoad={() => setLoading(false)}
|
||||
style={{ display: loading ? "none" : "block" }}
|
||||
/>
|
||||
|
||||
@@ -4,64 +4,64 @@ import { Constants } from "app/constants"
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Stack gap="xs">
|
||||
<Table striped highlightOnHover>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Refresh</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>R</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open next entry</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>J</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open previous entry</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>K</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Set focus on next entry without opening it</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>N</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Set focus on previous entry without opening it</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>P</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Move the page down</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Space</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Move the page up</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Shift</Trans>
|
||||
</Kbd>
|
||||
@@ -69,85 +69,85 @@ export function KeyboardShortcutsHelp() {
|
||||
<Kbd>
|
||||
<Trans>Space</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open/close current entry</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>O</Kbd>
|
||||
<span>, </span>
|
||||
<Kbd>
|
||||
<Trans>Enter</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open current entry in a new tab</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open current entry in a new tab in the background</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>B</Kbd>
|
||||
<span>*, </span>
|
||||
<Kbd>
|
||||
<Trans>Middle click</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Toggle read status of current entry</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>M</Kbd>
|
||||
<span>, </span>
|
||||
<Trans>Swipe header to the right</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans>Swipe header to the left</Trans>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Toggle starred status of current entry</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>S</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Mark all entries as read</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Shift</Trans>
|
||||
</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>A</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Go to the All view</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>G</Kbd>
|
||||
<span> </span>
|
||||
<Kbd>A</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Navigate to a subscription by entering its name</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Ctrl</Trans>
|
||||
</Kbd>
|
||||
@@ -157,23 +157,23 @@ export function KeyboardShortcutsHelp() {
|
||||
<Kbd>G</Kbd>
|
||||
<span> </span>
|
||||
<Kbd>U</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show entry menu (desktop)</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Right click</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show native menu (desktop)</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Shift</Trans>
|
||||
</Kbd>
|
||||
@@ -181,35 +181,35 @@ export function KeyboardShortcutsHelp() {
|
||||
<Kbd>
|
||||
<Trans>Right click</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show entry menu (mobile)</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Long press</Trans>
|
||||
</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Toggle sidebar</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>F</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show keyboard shortcut help</Trans>
|
||||
</td>
|
||||
<td>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>?</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Box>
|
||||
<span>* </span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Center, Loader as MantineLoader } from "@mantine/core"
|
||||
export function Loader() {
|
||||
return (
|
||||
<Center>
|
||||
<MantineLoader size="xl" variant="bars" />
|
||||
<MantineLoader size="lg" type="bars" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ export interface LogoProps {
|
||||
}
|
||||
|
||||
export function Logo(props: LogoProps) {
|
||||
return <Image src={logo} width={props.size} />
|
||||
return <Image src={logo} w={props.size} />
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Trans } from "@lingui/macro"
|
||||
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { UserModel } from "app/types"
|
||||
import { type AdminSaveUserRequest, type UserModel } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbDeviceFloppy } from "react-icons/tb"
|
||||
@@ -14,8 +14,12 @@ interface UserEditProps {
|
||||
}
|
||||
|
||||
export function UserEdit(props: UserEditProps) {
|
||||
const form = useForm<UserModel>({
|
||||
initialValues: props.user ?? ({ enabled: true } as UserModel),
|
||||
const form = useForm<AdminSaveUserRequest>({
|
||||
initialValues: props.user ?? {
|
||||
name: "",
|
||||
enabled: true,
|
||||
admin: false,
|
||||
},
|
||||
})
|
||||
const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave })
|
||||
|
||||
@@ -35,11 +39,11 @@ export function UserEdit(props: UserEditProps) {
|
||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||
|
||||
<Group>
|
||||
<Group justify="right">
|
||||
<Button variant="default" onClick={props.onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Input, Textarea } from "@mantine/core"
|
||||
import RichCodeEditor from "components/code/RichCodeEditor"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import { ReactNode } from "react"
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
interface CodeEditorProps {
|
||||
description?: ReactNode
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMantineTheme } from "@mantine/core"
|
||||
import { Loader } from "components/Loader"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useAsync } from "react-async-hook"
|
||||
|
||||
const init = async () => {
|
||||
@@ -32,8 +32,8 @@ interface RichCodeEditorProps {
|
||||
}
|
||||
|
||||
function RichCodeEditor(props: RichCodeEditorProps) {
|
||||
const theme = useMantineTheme()
|
||||
const editorTheme = theme.colorScheme === "dark" ? "vs-dark" : "light"
|
||||
const colorScheme = useColorScheme()
|
||||
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
||||
|
||||
const { result: Editor } = useAsync(init, [])
|
||||
if (!Editor) return <Loader />
|
||||
|
||||
11
commafeed-client/src/components/content/BasicHtmlStyles.tsx
Normal file
11
commafeed-client/src/components/content/BasicHtmlStyles.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TypographyStylesProvider } from "@mantine/core"
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* This component is used to provide basic styles to html typography elements.
|
||||
*
|
||||
* see https://mantine.dev/core/typography-styles-provider/
|
||||
*/
|
||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import { Box, createStyles, Mark, TypographyStylesProvider } from "@mantine/core"
|
||||
import { Box, Mark } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import escapeStringRegexp from "escape-string-regexp"
|
||||
import { ChildrenNode, Interweave, Matcher, MatchResponse, Node, TransformCallback } from "interweave"
|
||||
import { type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
||||
import React from "react"
|
||||
import { tss } from "tss"
|
||||
|
||||
export interface ContentProps {
|
||||
content: string
|
||||
highlight?: string
|
||||
}
|
||||
|
||||
const useStyles = createStyles(theme => ({
|
||||
const useStyles = tss.create(() => ({
|
||||
content: {
|
||||
// break long links or long words
|
||||
overflowWrap: "anywhere",
|
||||
"& a": {
|
||||
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
|
||||
color: "inherit",
|
||||
textDecoration: "underline",
|
||||
},
|
||||
"& iframe": {
|
||||
maxWidth: "100%",
|
||||
@@ -61,7 +64,7 @@ const transform: TransformCallback = node => {
|
||||
}
|
||||
|
||||
class HighlightMatcher extends Matcher {
|
||||
private search: string
|
||||
private readonly search: string
|
||||
|
||||
constructor(search: string) {
|
||||
super("highlight")
|
||||
@@ -73,12 +76,10 @@ class HighlightMatcher extends Matcher {
|
||||
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars
|
||||
replaceWith(children: ChildrenNode, props: unknown): Node {
|
||||
return <Mark>{children}</Mark>
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
asTag(): string {
|
||||
return "span"
|
||||
}
|
||||
@@ -90,12 +91,13 @@ const Content = React.memo((props: ContentProps) => {
|
||||
const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : []
|
||||
|
||||
return (
|
||||
<TypographyStylesProvider>
|
||||
<BasicHtmlStyles>
|
||||
<Box className={classes.content}>
|
||||
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
||||
</Box>
|
||||
</TypographyStylesProvider>
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
})
|
||||
Content.displayName = "Content"
|
||||
|
||||
export { Content }
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { TypographyStylesProvider } from "@mantine/core"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
|
||||
export function Enclosure(props: { enclosureType: string; enclosureUrl: string }) {
|
||||
const hasVideo = props.enclosureType && props.enclosureType.indexOf("video") === 0
|
||||
const hasAudio = props.enclosureType && props.enclosureType.indexOf("audio") === 0
|
||||
const hasImage = props.enclosureType && props.enclosureType.indexOf("image") === 0
|
||||
const hasVideo = props.enclosureType?.startsWith("video")
|
||||
const hasAudio = props.enclosureType?.startsWith("audio")
|
||||
const hasImage = props.enclosureType?.startsWith("image")
|
||||
|
||||
return (
|
||||
<TypographyStylesProvider>
|
||||
<BasicHtmlStyles>
|
||||
{hasVideo && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video controls>
|
||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||
</video>
|
||||
)}
|
||||
{hasAudio && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio controls>
|
||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||
</audio>
|
||||
)}
|
||||
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
|
||||
</TypographyStylesProvider>
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Trans } from "@lingui/macro"
|
||||
import { Box } from "@mantine/core"
|
||||
import { openModal } from "@mantine/modals"
|
||||
import { Constants } from "app/constants"
|
||||
import { type ExpendableEntry } from "app/entries/slice"
|
||||
import {
|
||||
ExpendableEntry,
|
||||
loadMoreEntries,
|
||||
markAllEntries,
|
||||
markEntry,
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
selectNextEntry,
|
||||
selectPreviousEntry,
|
||||
starEntry,
|
||||
} from "app/slices/entries"
|
||||
import { redirectToRootCategory } from "app/slices/redirect"
|
||||
import { toggleSidebar } from "app/slices/tree"
|
||||
} from "app/entries/thunks"
|
||||
import { redirectToRootCategory } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { toggleSidebar } from "app/tree/slice"
|
||||
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
|
||||
import { Loader } from "components/Loader"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
@@ -91,7 +91,7 @@ export function FeedEntries() {
|
||||
)
|
||||
}
|
||||
|
||||
const swipedRight = (entry: ExpendableEntry) => dispatch(markEntry({ entry, read: !entry.read }))
|
||||
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
|
||||
|
||||
// close context menu on scroll
|
||||
useEffect(() => {
|
||||
@@ -128,42 +128,50 @@ export function FeedEntries() {
|
||||
return () => window.removeEventListener("scroll", listener)
|
||||
}, [dispatch, contextMenu, entries, viewMode, scrollMarks, scrollingToEntry])
|
||||
|
||||
useMousetrap("r", () => dispatch(reloadEntries()))
|
||||
useMousetrap("j", () =>
|
||||
dispatch(
|
||||
selectNextEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||
useMousetrap(
|
||||
"j",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectNextEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap("n", () =>
|
||||
dispatch(
|
||||
selectNextEntry({
|
||||
expand: false,
|
||||
markAsRead: false,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
useMousetrap(
|
||||
"n",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectNextEntry({
|
||||
expand: false,
|
||||
markAsRead: false,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap("k", () =>
|
||||
dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
useMousetrap(
|
||||
"k",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap("p", () =>
|
||||
dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: false,
|
||||
markAsRead: false,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
useMousetrap(
|
||||
"p",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: false,
|
||||
markAsRead: false,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap("space", () => {
|
||||
if (selectedEntry) {
|
||||
@@ -271,12 +279,13 @@ export function FeedEntries() {
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: entriesTimestamp,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
useMousetrap("g a", () => dispatch(redirectToRootCategory()))
|
||||
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
||||
useMousetrap("f", () => dispatch(toggleSidebar()))
|
||||
useMousetrap("?", () =>
|
||||
openModal({
|
||||
@@ -291,7 +300,7 @@ export function FeedEntries() {
|
||||
<InfiniteScroll
|
||||
id="entries"
|
||||
initialLoad={false}
|
||||
loadMore={() => !loading && dispatch(loadMoreEntries())}
|
||||
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
||||
hasMore={hasMore}
|
||||
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
||||
>
|
||||
@@ -311,7 +320,7 @@ export function FeedEntries() {
|
||||
onHeaderClick={event => headerClicked(entry, event)}
|
||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||
onBodyClick={() => bodyClicked(entry)}
|
||||
onSwipedRight={() => swipedRight(entry)}
|
||||
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Box, createStyles, Divider, Paper } from "@mantine/core"
|
||||
import { MantineNumberSize } from "@mantine/styles"
|
||||
import { Box, Divider, type MantineRadius, type MantineSpacing, type MantineTheme, Paper, useMantineTheme } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { Entry, ViewMode } from "app/types"
|
||||
import { type Entry, type ViewMode } from "app/types"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useViewMode } from "hooks/useViewMode"
|
||||
import React from "react"
|
||||
import { useSwipeable } from "react-swipeable"
|
||||
import { tss } from "tss"
|
||||
import { FeedEntryBody } from "./FeedEntryBody"
|
||||
import { FeedEntryCompactHeader } from "./FeedEntryCompactHeader"
|
||||
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
||||
@@ -20,88 +21,110 @@ interface FeedEntryProps {
|
||||
onHeaderClick: (e: React.MouseEvent) => void
|
||||
onHeaderRightClick: (e: React.MouseEvent) => void
|
||||
onBodyClick: (e: React.MouseEvent) => void
|
||||
onSwipedRight: () => void
|
||||
onSwipedLeft: () => void
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, props: FeedEntryProps & { viewMode?: ViewMode }) => {
|
||||
let backgroundColor
|
||||
if (theme.colorScheme === "dark") {
|
||||
backgroundColor = props.entry.read ? "inherit" : theme.colors.dark[5]
|
||||
} else {
|
||||
backgroundColor = props.entry.read && !props.expanded ? theme.colors.gray[0] : "inherit"
|
||||
}
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
read: boolean
|
||||
expanded: boolean
|
||||
viewMode: ViewMode
|
||||
rtl: boolean
|
||||
showSelectionIndicator: boolean
|
||||
maxWidth?: number
|
||||
}>()
|
||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
|
||||
let backgroundColor
|
||||
if (colorScheme === "dark") {
|
||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||
} else {
|
||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||
}
|
||||
|
||||
let marginY = 10
|
||||
if (props.viewMode === "title") {
|
||||
marginY = 2
|
||||
} else if (props.viewMode === "cozy") {
|
||||
marginY = 6
|
||||
}
|
||||
let marginY = 10
|
||||
if (viewMode === "title") {
|
||||
marginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
marginY = 6
|
||||
}
|
||||
|
||||
let mobileMarginY = 6
|
||||
if (props.viewMode === "title") {
|
||||
mobileMarginY = 2
|
||||
} else if (props.viewMode === "cozy") {
|
||||
mobileMarginY = 4
|
||||
}
|
||||
let mobileMarginY = 6
|
||||
if (viewMode === "title") {
|
||||
mobileMarginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
mobileMarginY = 4
|
||||
}
|
||||
|
||||
let backgroundHoverColor = backgroundColor
|
||||
if (!props.expanded && !props.entry.read) {
|
||||
backgroundHoverColor = theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||
}
|
||||
let backgroundHoverColor = backgroundColor
|
||||
if (!expanded && !read) {
|
||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||
}
|
||||
|
||||
let paperBorderLeftColor
|
||||
if (props.showSelectionIndicator) {
|
||||
const borderLeftColor = theme.colorScheme === "dark" ? theme.colors.orange[4] : theme.colors.orange[6]
|
||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||
}
|
||||
let paperBorderLeftColor
|
||||
if (showSelectionIndicator) {
|
||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||
}
|
||||
|
||||
return {
|
||||
paper: {
|
||||
backgroundColor,
|
||||
borderLeftColor: paperBorderLeftColor,
|
||||
marginTop: marginY,
|
||||
marginBottom: marginY,
|
||||
[theme.fn.smallerThan(Constants.layout.mobileBreakpoint)]: {
|
||||
marginTop: mobileMarginY,
|
||||
marginBottom: mobileMarginY,
|
||||
},
|
||||
"@media (hover: hover)": {
|
||||
"&:hover": {
|
||||
backgroundColor: backgroundHoverColor,
|
||||
return {
|
||||
paper: {
|
||||
backgroundColor,
|
||||
borderLeftColor: paperBorderLeftColor,
|
||||
marginTop: marginY,
|
||||
marginBottom: marginY,
|
||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||
marginTop: mobileMarginY,
|
||||
marginBottom: mobileMarginY,
|
||||
},
|
||||
"@media (hover: hover)": {
|
||||
"&:hover": {
|
||||
backgroundColor: backgroundHoverColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
headerLink: {
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
},
|
||||
body: {
|
||||
direction: props.entry.rtl ? "rtl" : "ltr",
|
||||
maxWidth: props.maxWidth ?? "100%",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export function FeedEntry(props: FeedEntryProps) {
|
||||
const { viewMode } = useViewMode()
|
||||
const { classes, cx } = useStyles({ ...props, viewMode })
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: props.onSwipedRight,
|
||||
headerLink: {
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
},
|
||||
body: {
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
maxWidth: maxWidth ?? "100%",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let paddingX: MantineNumberSize = "xs"
|
||||
export function FeedEntry(props: FeedEntryProps) {
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { viewMode } = useViewMode()
|
||||
const { classes, cx } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
read: props.entry.read,
|
||||
expanded: props.expanded,
|
||||
viewMode,
|
||||
rtl: props.entry.rtl,
|
||||
showSelectionIndicator: props.showSelectionIndicator,
|
||||
maxWidth: props.maxWidth,
|
||||
})
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: props.onSwipedLeft,
|
||||
})
|
||||
|
||||
let paddingX: MantineSpacing = "xs"
|
||||
if (viewMode === "title" || viewMode === "cozy") paddingX = 6
|
||||
|
||||
let paddingY: MantineNumberSize = "xs"
|
||||
let paddingY: MantineSpacing = "xs"
|
||||
if (viewMode === "title") {
|
||||
paddingY = 4
|
||||
} else if (viewMode === "cozy") {
|
||||
paddingY = 8
|
||||
}
|
||||
|
||||
let borderRadius: MantineNumberSize = "sm"
|
||||
let borderRadius: MantineRadius = "sm"
|
||||
if (viewMode === "title") {
|
||||
borderRadius = 0
|
||||
} else if (viewMode === "cozy") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { Entry } from "app/types"
|
||||
import { type Entry } from "app/types"
|
||||
import { Content } from "./Content"
|
||||
import { Enclosure } from "./Enclosure"
|
||||
import { Media } from "./Media"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Box, createStyles, Text } from "@mantine/core"
|
||||
import { Entry } from "app/types"
|
||||
import { Box, Text } from "@mantine/core"
|
||||
import { type Entry } from "app/types"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { tss } from "tss"
|
||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||
import { FeedFavicon } from "./FeedFavicon"
|
||||
|
||||
@@ -9,39 +11,49 @@ export interface FeedEntryHeaderProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, props: FeedEntryHeaderProps) => ({
|
||||
wrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
columnGap: "10px",
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1,
|
||||
fontWeight: theme.colorScheme === "light" && !props.entry.read ? "bold" : "inherit",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
feedName: {
|
||||
width: "145px",
|
||||
minWidth: "145px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
date: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}))
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
colorScheme: "light" | "dark"
|
||||
read: boolean
|
||||
}>()
|
||||
.create(({ colorScheme, read }) => ({
|
||||
wrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
columnGap: "10px",
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1,
|
||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
feedName: {
|
||||
width: "145px",
|
||||
minWidth: "145px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
date: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
||||
const { classes } = useStyles(props)
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
colorScheme,
|
||||
read: props.entry.read,
|
||||
})
|
||||
return (
|
||||
<Box className={classes.wrapper}>
|
||||
<Box>
|
||||
<FeedFavicon url={props.entry.iconUrl} />
|
||||
</Box>
|
||||
<OnDesktop>
|
||||
<Text color="dimmed" className={classes.feedName}>
|
||||
<Text c="dimmed" className={classes.feedName}>
|
||||
{props.entry.feedName}
|
||||
</Text>
|
||||
</OnDesktop>
|
||||
@@ -49,7 +61,7 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
||||
<FeedEntryTitle entry={props.entry} />
|
||||
</Box>
|
||||
<OnDesktop>
|
||||
<Text color="dimmed" className={classes.date}>
|
||||
<Text c="dimmed" className={classes.date}>
|
||||
<RelativeDate date={props.entry.date} />
|
||||
</Text>
|
||||
</OnDesktop>
|
||||
|
||||
@@ -1,40 +1,50 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { createStyles, Group } from "@mantine/core"
|
||||
import { Group, type MantineTheme, useMantineTheme } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/slices/entries"
|
||||
import { redirectToFeed } from "app/slices/redirect"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
|
||||
import { redirectToFeed } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Entry } from "app/types"
|
||||
import { type Entry } from "app/types"
|
||||
import { truncate } from "app/utils"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { Item, Menu, Separator } from "react-contexify"
|
||||
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
|
||||
interface FeedEntryContextMenuProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
const useStyles = createStyles(theme => ({
|
||||
menu: {
|
||||
// apply mantine theme from MenuItem.styles.ts
|
||||
fontSize: theme.fontSizes.sm,
|
||||
"--contexify-item-color": `${theme.colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
||||
"--contexify-activeItem-color": `${theme.colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
||||
"--contexify-activeItem-bgColor": `${
|
||||
theme.colorScheme === "dark" ? theme.fn.rgba(theme.colors.dark[3], 0.35) : theme.colors.gray[1]
|
||||
} !important`,
|
||||
},
|
||||
}))
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
}>()
|
||||
.create(({ theme, colorScheme }) => ({
|
||||
menu: {
|
||||
// apply mantine theme from MenuItem.styles.ts
|
||||
fontSize: theme.fontSizes.sm,
|
||||
"--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-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`,
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
||||
const { classes, theme } = useStyles()
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
})
|
||||
const sourceType = useAppSelector(state => state.entries.source.type)
|
||||
const dispatch = useAppDispatch()
|
||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||
|
||||
return (
|
||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={theme.colorScheme} animation={false} className={classes.menu}>
|
||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
||||
<Item
|
||||
onClick={() => {
|
||||
window.open(props.entry.url, "_blank", "noreferrer")
|
||||
@@ -60,19 +70,19 @@ export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Item onClick={() => dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||
<Group>
|
||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
<Item onClick={() => dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||
<Group>
|
||||
{props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck size={iconSize} />}
|
||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
<Item onClick={() => dispatch(markEntriesUpToEntry(props.entry))}>
|
||||
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
||||
<Group>
|
||||
<TbArrowBarToDown size={iconSize} />
|
||||
<Trans>Mark as read up to here</Trans>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { Group, Indicator, MultiSelect, Popover } from "@mantine/core"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/slices/entries"
|
||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Entry } from "app/types"
|
||||
import { type Entry } from "app/types"
|
||||
import { ActionButton } from "components/ActionButton"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
@@ -22,9 +22,15 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
|
||||
const showSharingButtons = sharingSettings && Object.values(sharingSettings).some(v => v)
|
||||
|
||||
const readStatusButtonClicked = () => dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))
|
||||
const onTagsChange = (values: string[]) =>
|
||||
dispatch(
|
||||
const readStatusButtonClicked = async () =>
|
||||
await dispatch(
|
||||
markEntry({
|
||||
entry: props.entry,
|
||||
read: !props.entry.read,
|
||||
})
|
||||
)
|
||||
const onTagsChange = async (values: string[]) =>
|
||||
await dispatch(
|
||||
tagEntry({
|
||||
entryId: +props.entry.id,
|
||||
tags: values,
|
||||
@@ -32,8 +38,8 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Group position="apart">
|
||||
<Group spacing={spacing}>
|
||||
<Group justify="space-between">
|
||||
<Group gap={spacing}>
|
||||
{props.entry.markable && (
|
||||
<ActionButton
|
||||
icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />}
|
||||
@@ -44,7 +50,14 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
<ActionButton
|
||||
icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />}
|
||||
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
onClick={() => dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}
|
||||
onClick={async () =>
|
||||
await dispatch(
|
||||
starEntry({
|
||||
entry: props.entry,
|
||||
starred: !props.entry.starred,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showSharingButtons && (
|
||||
@@ -59,22 +72,21 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
)}
|
||||
|
||||
{tags && (
|
||||
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
|
||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||
<Popover.Target>
|
||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
||||
<ActionButton icon={<TbTag size={18} />} label={<Trans>Tags</Trans>} />
|
||||
</Indicator>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MultiSelect
|
||||
<TagsInput
|
||||
placeholder={t`Tags`}
|
||||
data={tags}
|
||||
placeholder="Tags"
|
||||
searchable
|
||||
creatable
|
||||
autoFocus
|
||||
getCreateLabel={query => t`Create tag: ${query}`}
|
||||
value={props.entry.tags}
|
||||
onChange={onTagsChange}
|
||||
comboboxProps={{
|
||||
withinPortal: false,
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
@@ -88,7 +100,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
<ActionButton
|
||||
icon={<TbArrowBarToDown size={18} />}
|
||||
label={<Trans>Mark as read up to here</Trans>}
|
||||
onClick={() => dispatch(markEntriesUpToEntry(props.entry))}
|
||||
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
|
||||
/>
|
||||
</Group>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Box, createStyles, Space, Text } from "@mantine/core"
|
||||
import { Entry } from "app/types"
|
||||
import { Box, Space, Text } from "@mantine/core"
|
||||
import { type Entry } from "app/types"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { tss } from "tss"
|
||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||
import { FeedFavicon } from "./FeedFavicon"
|
||||
|
||||
@@ -9,18 +11,28 @@ export interface FeedEntryHeaderProps {
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, props: FeedEntryHeaderProps) => ({
|
||||
headerText: {
|
||||
fontWeight: theme.colorScheme === "light" && !props.entry.read ? "bold" : "inherit",
|
||||
},
|
||||
headerSubtext: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: "90%",
|
||||
},
|
||||
}))
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
colorScheme: "light" | "dark"
|
||||
read: boolean
|
||||
}>()
|
||||
.create(({ colorScheme, read }) => ({
|
||||
headerText: {
|
||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||
},
|
||||
headerSubtext: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: "90%",
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
||||
const { classes } = useStyles(props)
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
colorScheme,
|
||||
read: props.entry.read,
|
||||
})
|
||||
return (
|
||||
<Box>
|
||||
<Box className={classes.headerText}>
|
||||
@@ -29,7 +41,7 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
||||
<Box className={classes.headerSubtext}>
|
||||
<FeedFavicon url={props.entry.iconUrl} />
|
||||
<Space w={6} />
|
||||
<Text color="dimmed">
|
||||
<Text c="dimmed">
|
||||
{props.entry.feedName}
|
||||
<span> · </span>
|
||||
<RelativeDate date={props.entry.date} />
|
||||
@@ -37,7 +49,7 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
||||
</Box>
|
||||
{props.expanded && (
|
||||
<Box className={classes.headerSubtext}>
|
||||
<Text color="dimmed">
|
||||
<Text c="dimmed">
|
||||
{props.entry.author && <span>by {props.entry.author}</span>}
|
||||
{props.entry.author && props.entry.categories && <span> · </span>}
|
||||
{props.entry.categories && <span>{props.entry.categories}</span>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Highlight } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { Entry } from "app/types"
|
||||
import { type Entry } from "app/types"
|
||||
|
||||
export interface FeedEntryTitleProps {
|
||||
entry: Entry
|
||||
@@ -11,6 +11,7 @@ export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
||||
const keywords = search?.split(" ")
|
||||
return (
|
||||
<Highlight
|
||||
inherit
|
||||
highlight={keywords ?? ""}
|
||||
// make sure ellipsis is shown when title is too long
|
||||
span
|
||||
|
||||
@@ -16,7 +16,6 @@ export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
||||
placeholderHeight={size}
|
||||
placeholderBackgroundColor="inherit"
|
||||
placeholderIconSize={size}
|
||||
placeholderIconColor="inherit"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, TypographyStylesProvider } from "@mantine/core"
|
||||
import { Box } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { Content } from "./Content"
|
||||
|
||||
@@ -20,7 +21,7 @@ export function Media(props: MediaProps) {
|
||||
maxWidth: Constants.layout.entryMaxWidth,
|
||||
})
|
||||
return (
|
||||
<TypographyStylesProvider>
|
||||
<BasicHtmlStyles>
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={props.thumbnailUrl}
|
||||
alt="media thumbnail"
|
||||
@@ -34,6 +35,6 @@ export function Media(props: MediaProps) {
|
||||
<Content content={props.description} />
|
||||
</Box>
|
||||
)}
|
||||
</TypographyStylesProvider>
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { ActionIcon, Box, createStyles, SimpleGrid } from "@mantine/core"
|
||||
import { ActionIcon, Box, type MantineTheme, SimpleGrid, useMantineTheme } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { SharingSettings } from "app/types"
|
||||
import { IconType } from "react-icons"
|
||||
import { type SharingSettings } from "app/types"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { type IconType } from "react-icons"
|
||||
import { tss } from "tss"
|
||||
|
||||
type Color = `#${string}`
|
||||
|
||||
const useStyles = createStyles((theme, props: { color: Color }) => ({
|
||||
socialIcon: {
|
||||
color: props.color,
|
||||
backgroundColor: theme.colorScheme === "dark" ? theme.colors.gray[2] : "white",
|
||||
borderRadius: "50%",
|
||||
},
|
||||
}))
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
color: Color
|
||||
}>()
|
||||
.create(({ theme, colorScheme, color }) => ({
|
||||
socialIcon: {
|
||||
color,
|
||||
backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white",
|
||||
borderRadius: "50%",
|
||||
},
|
||||
}))
|
||||
|
||||
function ShareButton({ url, icon, color }: { url: string; icon: IconType; color: Color }) {
|
||||
const { classes } = useStyles({ color })
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
color,
|
||||
})
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -23,7 +37,7 @@ function ShareButton({ url, icon, color }: { url: string; icon: IconType; color:
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionIcon>
|
||||
<ActionIcon variant="transparent">
|
||||
<a href={url} target="_blank" rel="noreferrer" onClick={onClick}>
|
||||
<Box p={6} className={classes.socialIcon}>
|
||||
{icon({ size: 18 })}
|
||||
@@ -41,7 +55,7 @@ export function ShareButtons(props: { url: string; description: string }) {
|
||||
return (
|
||||
<SimpleGrid cols={4}>
|
||||
{(Object.keys(Constants.sharing) as Array<keyof SharingSettings>)
|
||||
.filter(site => sharingSettings && sharingSettings[site])
|
||||
.filter(site => sharingSettings?.[site])
|
||||
.map(site => (
|
||||
<ShareButton
|
||||
key={site}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { t, Trans } from "@lingui/macro"
|
||||
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { reloadTree } from "app/slices/tree"
|
||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { AddCategoryRequest } from "app/types"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import { type AddCategoryRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbFolderPlus } from "react-icons/tb"
|
||||
@@ -35,11 +35,11 @@ export function AddCategory() {
|
||||
<Stack>
|
||||
<TextInput label={<Trans>Category</Trans>} placeholder={t`Category`} {...form.getInputProps("name")} required />
|
||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||
<Group position="center">
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Group justify="center">
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbFolderPlus size={16} />} loading={addCategory.loading}>
|
||||
<Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { Select, SelectItem, SelectProps } from "@mantine/core"
|
||||
import { Select, type SelectProps } from "@mantine/core"
|
||||
import { type ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { type Category } from "app/types"
|
||||
import { flattenCategoryTree } from "app/utils"
|
||||
|
||||
type CategorySelectProps = Partial<SelectProps> & {
|
||||
@@ -12,14 +14,32 @@ type CategorySelectProps = Partial<SelectProps> & {
|
||||
export function CategorySelect(props: CategorySelectProps) {
|
||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
||||
const categories = rootCategory && flattenCategoryTree(rootCategory)
|
||||
const selectData: SelectItem[] | undefined = categories
|
||||
const categoriesById = categories?.reduce((map, c) => {
|
||||
map.set(c.id, c)
|
||||
return map
|
||||
}, new Map<string, Category>())
|
||||
const categoryLabel = (cat: Category) => {
|
||||
let label = cat.name
|
||||
|
||||
while (cat.parentId) {
|
||||
const parent = categoriesById?.get(cat.parentId)
|
||||
if (!parent) {
|
||||
break
|
||||
}
|
||||
label = `${parent.name} → ${label}`
|
||||
cat = parent
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
const selectData: ComboboxItem[] | undefined = categories
|
||||
?.filter(c => c.id !== Constants.categories.all.id)
|
||||
.filter(c => !props.withoutCategoryIds || !props.withoutCategoryIds.includes(c.id))
|
||||
.sort((c1, c2) => c1.name.localeCompare(c2.name))
|
||||
.filter(c => !props.withoutCategoryIds?.includes(c.id))
|
||||
.map(c => ({
|
||||
label: c.parentName ? t`${c.name} (in ${c.parentName})` : c.name,
|
||||
label: categoryLabel(c),
|
||||
value: c.id,
|
||||
}))
|
||||
.sort((c1, c2) => c1.label.localeCompare(c2.label))
|
||||
if (props.withAll) {
|
||||
selectData?.unshift({
|
||||
label: t`All`,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { t, Trans } from "@lingui/macro"
|
||||
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { reloadTree } from "app/slices/tree"
|
||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbFileImport } from "react-icons/tb"
|
||||
@@ -33,11 +33,13 @@ export function ImportOpml() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.onSubmit(v => importOpml.execute(v.file))}>
|
||||
<form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}>
|
||||
<Stack>
|
||||
<FileInput
|
||||
label={<Trans>OPML file</Trans>}
|
||||
placeholder={t`OPML file`}
|
||||
leftSection={<TbFileImport />}
|
||||
// https://github.com/mantinedev/mantine/issues/5401
|
||||
{...{ placeholder: t`OPML file` }}
|
||||
description={
|
||||
<Trans>
|
||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
||||
@@ -48,11 +50,11 @@ export function ImportOpml() {
|
||||
required
|
||||
accept="application/xml"
|
||||
/>
|
||||
<Group position="center">
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Group justify="center">
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbFileImport size={16} />} loading={importOpml.loading}>
|
||||
<Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}>
|
||||
<Trans>Import</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectToFeed, redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { reloadTree } from "app/slices/tree"
|
||||
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { FeedInfoRequest, SubscribeRequest } from "app/types"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import { type FeedInfoRequest, type SubscribeRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useState } from "react"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
@@ -46,8 +46,11 @@ export function Subscribe() {
|
||||
})
|
||||
|
||||
const previousStep = () => {
|
||||
if (activeStep === 0) dispatch(redirectToSelectedSource())
|
||||
else setActiveStep(activeStep - 1)
|
||||
if (activeStep === 0) {
|
||||
dispatch(redirectToSelectedSource())
|
||||
} else {
|
||||
setActiveStep(activeStep - 1)
|
||||
}
|
||||
}
|
||||
const nextStep = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
if (activeStep === 0) {
|
||||
@@ -80,7 +83,7 @@ export function Subscribe() {
|
||||
>
|
||||
<TextInput
|
||||
label={<Trans>Feed URL</Trans>}
|
||||
placeholder="http://www.mysite.com/rss"
|
||||
placeholder="https://www.mysite.com/rss"
|
||||
description={
|
||||
<Trans>
|
||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
||||
@@ -105,7 +108,7 @@ export function Subscribe() {
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
|
||||
<Group position="center" mt="xl">
|
||||
<Group justify="center" mt="xl">
|
||||
<Button variant="default" onClick={previousStep}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
@@ -115,7 +118,7 @@ export function Subscribe() {
|
||||
</Button>
|
||||
)}
|
||||
{activeStep === 1 && (
|
||||
<Button type="submit" leftIcon={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
||||
<Trans>Subscribe</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { ActionIcon, Box, Center, 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 { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/slices/entries"
|
||||
import { changeReadingMode, changeReadingOrder } from "app/slices/user"
|
||||
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
|
||||
import { ActionButton } from "components/ActionButton"
|
||||
import { Loader } from "components/Loader"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
TbSortAscending,
|
||||
TbSortDescending,
|
||||
TbUser,
|
||||
TbX,
|
||||
} from "react-icons/tb"
|
||||
import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
|
||||
import { ProfileMenu } from "./ProfileMenu"
|
||||
@@ -37,7 +36,7 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
return mobile ? (
|
||||
// on mobile use all available width
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
@@ -46,7 +45,7 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
{props.children}
|
||||
</Box>
|
||||
) : (
|
||||
<Group spacing={spacing}>{props.children}</Group>
|
||||
<Group gap={spacing}>{props.children}</Group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,8 +78,8 @@ export function Header() {
|
||||
<ActionButton
|
||||
icon={<TbArrowUp size={iconSize} />}
|
||||
label={<Trans>Previous</Trans>}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
onClick={async () =>
|
||||
await dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
@@ -92,8 +91,8 @@ export function Header() {
|
||||
<ActionButton
|
||||
icon={<TbArrowDown size={iconSize} />}
|
||||
label={<Trans>Next</Trans>}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
onClick={async () =>
|
||||
await dispatch(
|
||||
selectNextEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
@@ -108,7 +107,7 @@ export function Header() {
|
||||
<ActionButton
|
||||
icon={<TbRefresh size={iconSize} />}
|
||||
label={<Trans>Refresh</Trans>}
|
||||
onClick={() => dispatch(reloadEntries())}
|
||||
onClick={async () => await dispatch(reloadEntries())}
|
||||
/>
|
||||
<MarkAllAsReadButton iconSize={iconSize} />
|
||||
|
||||
@@ -117,12 +116,12 @@ export function Header() {
|
||||
<ActionButton
|
||||
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
||||
label={settings.readingMode === "all" ? <Trans>All</Trans> : <Trans>Unread</Trans>}
|
||||
onClick={() => dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
||||
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
||||
label={settings.readingOrder === "asc" ? <Trans>Asc</Trans> : <Trans>Desc</Trans>}
|
||||
onClick={() => dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
||||
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
||||
/>
|
||||
|
||||
<Popover>
|
||||
@@ -132,16 +131,12 @@ export function Header() {
|
||||
</Indicator>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<form onSubmit={searchForm.onSubmit(values => dispatch(search(values.search)))}>
|
||||
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
|
||||
<TextInput
|
||||
placeholder={t`Search`}
|
||||
{...searchForm.getInputProps("search")}
|
||||
icon={<TbSearch size={iconSize} />}
|
||||
rightSection={
|
||||
<ActionIcon onClick={() => searchFromStore && dispatch(search(""))}>
|
||||
<TbX />
|
||||
</ActionIcon>
|
||||
}
|
||||
leftSection={<TbSearch size={iconSize} />}
|
||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
|
||||
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
|
||||
import { markAllEntries } from "app/slices/entries"
|
||||
import { markAllEntries } from "app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { ActionButton } from "components/ActionButton"
|
||||
import { useState } from "react"
|
||||
@@ -27,7 +27,8 @@ export function MarkAllAsReadButton(props: { iconSize: number }) {
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: entriesTimestamp,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -64,7 +65,7 @@ export function MarkAllAsReadButton(props: { iconSize: number }) {
|
||||
value={threshold}
|
||||
onChange={setThreshold}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpened(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
@@ -78,7 +79,8 @@ export function MarkAllAsReadButton(props: { iconSize: number }) {
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: entriesTimestamp - threshold * 24 * 60 * 60 * 1000,
|
||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Box, Divider, Group, Menu, SegmentedControl, SegmentedControlItem, useMantineColorScheme } from "@mantine/core"
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
type MantineColorScheme,
|
||||
Menu,
|
||||
SegmentedControl,
|
||||
type SegmentedControlItem,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { client } from "app/client"
|
||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/slices/redirect"
|
||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { ViewMode } from "app/types"
|
||||
import { type ViewMode } from "app/types"
|
||||
import { useViewMode } from "hooks/useViewMode"
|
||||
import { useState } from "react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import {
|
||||
TbChartLine,
|
||||
TbHeartFilled,
|
||||
@@ -19,6 +28,7 @@ import {
|
||||
TbPower,
|
||||
TbSettings,
|
||||
TbSun,
|
||||
TbSunMoon,
|
||||
TbUsers,
|
||||
TbWorldDownload,
|
||||
} from "react-icons/tb"
|
||||
@@ -27,56 +37,56 @@ interface ProfileMenuProps {
|
||||
control: React.ReactElement
|
||||
}
|
||||
|
||||
interface ViewModeControlItem extends SegmentedControlItem {
|
||||
value: ViewMode
|
||||
const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => {
|
||||
return (
|
||||
<Group>
|
||||
{icon}
|
||||
<Box ml={6}>{label}</Box>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
|
||||
interface ColorSchemeControlItem extends SegmentedControlItem {
|
||||
value: MantineColorScheme
|
||||
}
|
||||
|
||||
const colorSchemeData: ColorSchemeControlItem[] = [
|
||||
{
|
||||
value: "light",
|
||||
label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />,
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />,
|
||||
},
|
||||
]
|
||||
|
||||
interface ViewModeControlItem extends SegmentedControlItem {
|
||||
value: ViewMode
|
||||
}
|
||||
|
||||
const viewModeData: ViewModeControlItem[] = [
|
||||
{
|
||||
value: "title",
|
||||
label: (
|
||||
<Group>
|
||||
<TbList size={iconSize} />
|
||||
<Box ml={6}>
|
||||
<Trans>Compact</Trans>
|
||||
</Box>
|
||||
</Group>
|
||||
),
|
||||
label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />,
|
||||
},
|
||||
{
|
||||
value: "cozy",
|
||||
label: (
|
||||
<Group>
|
||||
<TbLayoutList size={iconSize} />
|
||||
<Box ml={6}>
|
||||
<Trans>Cozy</Trans>
|
||||
</Box>
|
||||
</Group>
|
||||
),
|
||||
label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />,
|
||||
},
|
||||
{
|
||||
value: "detailed",
|
||||
label: (
|
||||
<Group>
|
||||
<TbListDetails size={iconSize} />
|
||||
<Box ml={6}>
|
||||
<Trans>Detailed</Trans>
|
||||
</Box>
|
||||
</Group>
|
||||
),
|
||||
label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />,
|
||||
},
|
||||
{
|
||||
value: "expanded",
|
||||
label: (
|
||||
<Group>
|
||||
<TbNotes size={iconSize} />
|
||||
<Box ml={6}>
|
||||
<Trans>Expanded</Trans>
|
||||
</Box>
|
||||
</Group>
|
||||
),
|
||||
label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -86,8 +96,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
const profile = useAppSelector(state => state.user.profile)
|
||||
const admin = useAppSelector(state => state.user.profile?.admin)
|
||||
const dispatch = useAppDispatch()
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme()
|
||||
const dark = colorScheme === "dark"
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
||||
|
||||
const logout = () => {
|
||||
window.location.href = "logout"
|
||||
@@ -99,7 +108,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
<Menu.Dropdown>
|
||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||
<Menu.Item
|
||||
icon={<TbSettings size={iconSize} />}
|
||||
leftSection={<TbSettings size={iconSize} />}
|
||||
onClick={() => {
|
||||
dispatch(redirectToSettings())
|
||||
setOpened(false)
|
||||
@@ -108,9 +117,9 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
<Trans>Settings</Trans>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<TbWorldDownload size={iconSize} />}
|
||||
onClick={() =>
|
||||
client.feed.refreshAll().then(() => {
|
||||
leftSection={<TbWorldDownload size={iconSize} />}
|
||||
onClick={async () =>
|
||||
await client.feed.refreshAll().then(() => {
|
||||
showNotification({
|
||||
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
||||
color: "green",
|
||||
@@ -128,9 +137,14 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
<Menu.Label>
|
||||
<Trans>Theme</Trans>
|
||||
</Menu.Label>
|
||||
<Menu.Item icon={dark ? <TbSun size={iconSize} /> : <TbMoon size={iconSize} />} onClick={() => toggleColorScheme()}>
|
||||
{dark ? <Trans>Switch to light theme</Trans> : <Trans>Switch to dark theme</Trans>}
|
||||
</Menu.Item>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
orientation="vertical"
|
||||
data={colorSchemeData}
|
||||
value={colorScheme}
|
||||
onChange={e => setColorScheme(e as MantineColorScheme)}
|
||||
mb="xs"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -153,7 +167,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
<Trans>Admin</Trans>
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
icon={<TbUsers size={iconSize} />}
|
||||
leftSection={<TbUsers size={iconSize} />}
|
||||
onClick={() => {
|
||||
dispatch(redirectToAdminUsers())
|
||||
setOpened(false)
|
||||
@@ -162,7 +176,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
<Trans>Manage users</Trans>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<TbChartLine size={iconSize} />}
|
||||
leftSection={<TbChartLine size={iconSize} />}
|
||||
onClick={() => {
|
||||
dispatch(redirectToMetrics())
|
||||
setOpened(false)
|
||||
@@ -176,7 +190,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
<Divider />
|
||||
|
||||
<Menu.Item
|
||||
icon={<TbHeartFilled size={iconSize} color="red" />}
|
||||
leftSection={<TbHeartFilled size={iconSize} color="red" />}
|
||||
onClick={() => {
|
||||
dispatch(redirectToDonate())
|
||||
setOpened(false)
|
||||
@@ -186,7 +200,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
icon={<TbHelp size={iconSize} />}
|
||||
leftSection={<TbHelp size={iconSize} />}
|
||||
onClick={() => {
|
||||
dispatch(redirectToAbout())
|
||||
setOpened(false)
|
||||
@@ -194,7 +208,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
>
|
||||
<Trans>About</Trans>
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<TbPower size={iconSize} />} onClick={logout}>
|
||||
<Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}>
|
||||
<Trans>Logout</Trans>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MetricGauge } from "app/types"
|
||||
import { type MetricGauge } from "app/types"
|
||||
|
||||
interface MeterProps {
|
||||
gauge: MetricGauge
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { MetricMeter } from "app/types"
|
||||
import { type MetricMeter } from "app/types"
|
||||
|
||||
interface MeterProps {
|
||||
meter: MetricMeter
|
||||
|
||||
@@ -11,7 +11,7 @@ export function MetricAccordionItem({ metricKey, name, headerValue, children }:
|
||||
return (
|
||||
<Accordion.Item value={metricKey} key={metricKey}>
|
||||
<Accordion.Control>
|
||||
<Group position="apart">
|
||||
<Group justify="space-between">
|
||||
<Box>{name}</Box>
|
||||
<Box>{headerValue}</Box>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { MetricTimer } from "app/types"
|
||||
import { type MetricTimer } from "app/types"
|
||||
|
||||
interface MetricTimerProps {
|
||||
timer: MetricTimer
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Trans } from "@lingui/macro"
|
||||
import { Box, Button, Group, Stack } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Alert } from "components/Alert"
|
||||
import { CodeEditor } from "components/code/CodeEditor"
|
||||
@@ -69,10 +69,10 @@ export function CustomCodeSettings() {
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Divider, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { type SharingSettings } from "app/types"
|
||||
import {
|
||||
changeAlwaysScrollToEntry,
|
||||
changeCustomContextMenu,
|
||||
changeLanguage,
|
||||
changeMarkAllAsReadConfirmation,
|
||||
changeMobileFooter,
|
||||
changeScrollMarks,
|
||||
changeScrollSpeed,
|
||||
changeSharingSetting,
|
||||
changeShowRead,
|
||||
} from "app/slices/user"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { SharingSettings } from "app/types"
|
||||
} from "app/user/thunks"
|
||||
import { locales } from "i18n"
|
||||
|
||||
export function DisplaySettings() {
|
||||
@@ -23,6 +24,7 @@ export function DisplaySettings() {
|
||||
const alwaysScrollToEntry = useAppSelector(state => state.user.settings?.alwaysScrollToEntry)
|
||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@@ -35,43 +37,49 @@ export function DisplaySettings() {
|
||||
value: l.key,
|
||||
label: l.label,
|
||||
}))}
|
||||
onChange={s => s && dispatch(changeLanguage(s))}
|
||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
||||
checked={scrollSpeed ? scrollSpeed > 0 : false}
|
||||
onChange={e => dispatch(changeScrollSpeed(e.currentTarget.checked))}
|
||||
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Always scroll selected entry to the top of the page, even if it fits entirely on screen</Trans>}
|
||||
checked={alwaysScrollToEntry}
|
||||
onChange={e => dispatch(changeAlwaysScrollToEntry(e.currentTarget.checked))}
|
||||
onChange={async e => await dispatch(changeAlwaysScrollToEntry(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
||||
checked={showRead}
|
||||
onChange={e => dispatch(changeShowRead(e.currentTarget.checked))}
|
||||
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
||||
checked={scrollMarks}
|
||||
onChange={e => dispatch(changeScrollMarks(e.currentTarget.checked))}
|
||||
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
||||
checked={markAllAsReadConfirmation}
|
||||
onChange={e => dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
||||
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
||||
checked={customContextMenu}
|
||||
onChange={e => dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
||||
checked={mobileFooter}
|
||||
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
||||
@@ -81,8 +89,15 @@ export function DisplaySettings() {
|
||||
<Switch
|
||||
key={site}
|
||||
label={Constants.sharing[site].label}
|
||||
checked={sharingSettings && sharingSettings[site]}
|
||||
onChange={e => dispatch(changeSharingSetting({ site, value: e.currentTarget.checked }))}
|
||||
checked={sharingSettings?.[site]}
|
||||
onChange={async e =>
|
||||
await dispatch(
|
||||
changeSharingSetting({
|
||||
site,
|
||||
value: e.currentTarget.checked,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, St
|
||||
import { useForm } from "@mantine/form"
|
||||
import { openConfirmModal } from "@mantine/modals"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToLogin, redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { reloadProfile } from "app/slices/user"
|
||||
import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { ProfileModificationRequest } from "app/types"
|
||||
import { type ProfileModificationRequest } from "app/types"
|
||||
import { reloadProfile } from "app/user/thunks"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useEffect } from "react"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
@@ -49,7 +49,7 @@ export function ProfileSettings() {
|
||||
),
|
||||
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteProfile.execute(),
|
||||
onConfirm: async () => await deleteProfile.execute(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,7 +78,17 @@ export function ProfileSettings() {
|
||||
<form onSubmit={form.onSubmit(saveProfile.execute)}>
|
||||
<Stack>
|
||||
<TextInput label={<Trans>User name</Trans>} readOnly value={profile?.name} />
|
||||
<TextInput label={<Trans>API key</Trans>} readOnly value={profile?.apiKey} />
|
||||
<TextInput
|
||||
label={<Trans>API key</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API.
|
||||
Use the form at the bottom of the page to generate a new API key
|
||||
</Trans>
|
||||
}
|
||||
readOnly
|
||||
value={profile?.apiKey}
|
||||
/>
|
||||
|
||||
<Input.Wrapper
|
||||
label={<Trans>OPML export</Trans>}
|
||||
@@ -100,7 +110,7 @@ export function ProfileSettings() {
|
||||
description={
|
||||
<Trans>
|
||||
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
|
||||
The username is your user name and the password is your API key.
|
||||
Login with your username and your <u>API key</u>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
@@ -129,16 +139,16 @@ export function ProfileSettings() {
|
||||
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbDeviceFloppy size={16} />} loading={saveProfile.loading}>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveProfile.loading}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
color="red"
|
||||
leftIcon={<TbTrash size={16} />}
|
||||
leftSection={<TbTrash size={16} />}
|
||||
onClick={() => openDeleteProfileModal()}
|
||||
loading={deleteProfile.loading}
|
||||
>
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
redirectToFeedDetails,
|
||||
redirectToTag,
|
||||
redirectToTagDetails,
|
||||
} from "app/slices/redirect"
|
||||
import { collapseTreeCategory } from "app/slices/tree"
|
||||
} from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Category, Subscription } from "app/types"
|
||||
import { collapseTreeCategory } from "app/tree/thunks"
|
||||
import { type Category, type Subscription } from "app/types"
|
||||
import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
|
||||
import { Loader } from "components/Loader"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
@@ -36,8 +36,11 @@ export function Tree() {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const feedClicked = (e: React.MouseEvent, id: string) => {
|
||||
if (e.detail === 2) dispatch(redirectToFeedDetails(id))
|
||||
else dispatch(redirectToFeed(id))
|
||||
if (e.detail === 2) {
|
||||
dispatch(redirectToFeedDetails(id))
|
||||
} else {
|
||||
dispatch(redirectToFeed(id))
|
||||
}
|
||||
}
|
||||
const categoryClicked = (e: React.MouseEvent, id: string) => {
|
||||
if (e.detail === 2) {
|
||||
@@ -57,8 +60,11 @@ export function Tree() {
|
||||
)
|
||||
}
|
||||
const tagClicked = (e: React.MouseEvent, id: string) => {
|
||||
if (e.detail === 2) dispatch(redirectToTagDetails(id))
|
||||
else dispatch(redirectToTag(id))
|
||||
if (e.detail === 2) {
|
||||
dispatch(redirectToTagDetails(id))
|
||||
} else {
|
||||
dispatch(redirectToTag(id))
|
||||
}
|
||||
}
|
||||
|
||||
const allCategoryNode = () => (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Box, Center, createStyles } from "@mantine/core"
|
||||
import { Box, Center, type MantineTheme, useMantineTheme } from "@mantine/core"
|
||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||
import React, { ReactNode } from "react"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import React, { type ReactNode } from "react"
|
||||
import { tss } from "tss"
|
||||
import { UnreadCount } from "./UnreadCount"
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -16,40 +18,60 @@ interface TreeNodeProps {
|
||||
onIconClick?: (e: React.MouseEvent, id: string) => void
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, props: TreeNodeProps) => {
|
||||
let backgroundColor = "inherit"
|
||||
if (props.selected) backgroundColor = theme.colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[3]
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "dark" | "light"
|
||||
selected: boolean
|
||||
hasError: boolean
|
||||
hasUnread: boolean
|
||||
}>()
|
||||
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
|
||||
let backgroundColor = "inherit"
|
||||
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
||||
|
||||
let color
|
||||
if (props.hasError) color = theme.colors.red[6]
|
||||
else if (theme.colorScheme === "dark") color = props.unread > 0 ? theme.colors.dark[0] : theme.colors.dark[3]
|
||||
else color = props.unread > 0 ? theme.black : theme.colors.gray[6]
|
||||
let color
|
||||
if (hasError) {
|
||||
color = theme.colors.red[6]
|
||||
} else if (colorScheme === "dark") {
|
||||
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
||||
} else {
|
||||
color = hasUnread ? theme.black : theme.colors.gray[6]
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color,
|
||||
backgroundColor,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
||||
return {
|
||||
node: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color,
|
||||
backgroundColor,
|
||||
"&:hover": {
|
||||
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
||||
},
|
||||
},
|
||||
},
|
||||
nodeText: {
|
||||
flexGrow: 1,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}
|
||||
})
|
||||
nodeText: {
|
||||
flexGrow: 1,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export function TreeNode(props: TreeNodeProps) {
|
||||
const { classes } = useStyles(props)
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
selected: props.selected,
|
||||
hasError: props.hasError,
|
||||
hasUnread: props.unread > 0,
|
||||
})
|
||||
return (
|
||||
<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 && 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>
|
||||
</Box>
|
||||
<Box className={classes.nodeText}>{props.name}</Box>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { Box, Center, Kbd, TextInput } from "@mantine/core"
|
||||
import { openSpotlight, SpotlightAction, SpotlightProvider } from "@mantine/spotlight"
|
||||
import { redirectToFeed } from "app/slices/redirect"
|
||||
import { Spotlight, spotlight, type SpotlightActionData } from "@mantine/spotlight"
|
||||
import { redirectToFeed } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { Subscription } from "app/types"
|
||||
import { type Subscription } from "app/types"
|
||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||
import { useMousetrap } from "hooks/useMousetrap"
|
||||
import { TbSearch } from "react-icons/tb"
|
||||
@@ -15,17 +15,18 @@ export interface TreeSearchProps {
|
||||
export function TreeSearch(props: TreeSearchProps) {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const actions: SpotlightAction[] = props.feeds
|
||||
.sort((f1, f2) => f1.name.localeCompare(f2.name))
|
||||
const actions: SpotlightActionData[] = props.feeds
|
||||
.map(f => ({
|
||||
title: f.name,
|
||||
icon: <FeedFavicon url={f.iconUrl} />,
|
||||
onTrigger: () => dispatch(redirectToFeed(f.id)),
|
||||
id: `${f.id}`,
|
||||
label: f.name,
|
||||
leftSection: <FeedFavicon url={f.iconUrl} />,
|
||||
onClick: async () => await dispatch(redirectToFeed(f.id)),
|
||||
}))
|
||||
.sort((f1, f2) => f1.label.localeCompare(f2.label))
|
||||
|
||||
const searchIcon = <TbSearch size={18} />
|
||||
const rightSection = (
|
||||
<Center>
|
||||
<Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
<Box mx={5}>+</Box>
|
||||
<Kbd>K</Kbd>
|
||||
@@ -33,30 +34,35 @@ export function TreeSearch(props: TreeSearchProps) {
|
||||
)
|
||||
|
||||
// additional keyboard shortcut used by commafeed v1
|
||||
useMousetrap("g u", () => openSpotlight())
|
||||
useMousetrap("g u", () => spotlight.open())
|
||||
|
||||
return (
|
||||
<SpotlightProvider
|
||||
actions={actions}
|
||||
searchIcon={searchIcon}
|
||||
searchPlaceholder={t`Search`}
|
||||
shortcut="ctrl+k"
|
||||
nothingFoundMessage={<Trans>Nothing found</Trans>}
|
||||
>
|
||||
<>
|
||||
<TextInput
|
||||
placeholder={t`Search`}
|
||||
icon={searchIcon}
|
||||
leftSection={searchIcon}
|
||||
rightSectionWidth={100}
|
||||
rightSection={rightSection}
|
||||
styles={{
|
||||
input: { cursor: "pointer" },
|
||||
rightSection: { pointerEvents: "none" },
|
||||
input: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
onClick={() => openSpotlight()}
|
||||
onClick={() => spotlight.open()}
|
||||
// prevent focus
|
||||
onFocus={e => e.target.blur()}
|
||||
readOnly
|
||||
/>
|
||||
</SpotlightProvider>
|
||||
<Spotlight
|
||||
actions={actions}
|
||||
limit={10}
|
||||
shortcut="ctrl+k"
|
||||
searchProps={{
|
||||
leftSection: searchIcon,
|
||||
placeholder: t`Search`,
|
||||
}}
|
||||
nothingFound={<Trans>Nothing found</Trans>}
|
||||
></Spotlight>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge, createStyles, Tooltip } from "@mantine/core"
|
||||
import { Badge, Tooltip } from "@mantine/core"
|
||||
import { tss } from "tss"
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
const useStyles = tss.create(() => ({
|
||||
badge: {
|
||||
width: "3.2rem",
|
||||
// for some reason, mantine Badge has "cursor: 'default'"
|
||||
@@ -16,7 +17,9 @@ export function UnreadCount(props: { unreadCount: number }) {
|
||||
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
|
||||
return (
|
||||
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count}>
|
||||
<Badge className={classes.badge}>{count}</Badge>
|
||||
<Badge className={classes.badge} variant="light">
|
||||
{count}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
20
commafeed-client/src/hooks/useColorScheme.ts
Normal file
20
commafeed-client/src/hooks/useColorScheme.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// the color scheme to use to render components
|
||||
import { useMantineColorScheme } from "@mantine/core"
|
||||
import { useMediaQuery } from "@mantine/hooks"
|
||||
|
||||
export const useColorScheme = () => {
|
||||
const systemColorScheme = useMediaQuery(
|
||||
"(prefers-color-scheme: dark)",
|
||||
// passing undefined will use window.matchMedia(query) as default value
|
||||
undefined,
|
||||
{
|
||||
// get initial value synchronously and not in useEffect to avoid flash of light theme
|
||||
getInitialValueInEffect: false,
|
||||
}
|
||||
)
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
const { colorScheme } = useMantineColorScheme()
|
||||
return colorScheme === "auto" ? systemColorScheme : colorScheme
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMediaQuery } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
|
||||
export const useMobile = (breakpoint: string = Constants.layout.mobileBreakpoint) =>
|
||||
!useMediaQuery(`(min-width: ${breakpoint})`, undefined, {
|
||||
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
|
||||
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
|
||||
return !useMediaQuery(`(min-width: ${bp})`, undefined, {
|
||||
getInitialValueInEffect: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import mousetrap, { ExtendedKeyboardEvent } from "mousetrap"
|
||||
import mousetrap, { type ExtendedKeyboardEvent } from "mousetrap"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
type Callback = (e: ExtendedKeyboardEvent, combo: string) => void
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ViewMode } from "app/types"
|
||||
import { type ViewMode } from "app/types"
|
||||
import useLocalStorage from "use-local-storage"
|
||||
|
||||
export function useViewMode() {
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
import { setWebSocketConnected } from "app/slices/server"
|
||||
import { reloadTree } from "app/slices/tree"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { setWebSocketConnected } from "app/server/slice"
|
||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
|
||||
import { incrementUnreadCount } from "app/tree/slice"
|
||||
import { useEffect } from "react"
|
||||
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
||||
|
||||
const handleMessage = (dispatch: AppDispatch, message: string) => {
|
||||
const parts = message.split(":")
|
||||
const type = parts[0]
|
||||
if (type === "new-feed-entries") {
|
||||
dispatch(
|
||||
incrementUnreadCount({
|
||||
feedId: +parts[1],
|
||||
amount: +parts[2],
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const useWebSocket = () => {
|
||||
const websocketEnabled = useAppSelector(state => state.server.serverInfos?.websocketEnabled)
|
||||
const websocketPingInterval = useAppSelector(state => state.server.serverInfos?.websocketPingInterval)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const wsProtocol = currentUrl.protocol === "http:" ? "ws" : "wss"
|
||||
const wsUrl = `${wsProtocol}://${currentUrl.hostname}:${currentUrl.port}/ws`
|
||||
let ws: WebsocketHeartbeatJs | undefined
|
||||
|
||||
const ws = new WebsocketHeartbeatJs({
|
||||
url: wsUrl,
|
||||
pingMsg: "ping",
|
||||
// ping interval, just under a minute to prevent firewalls from closing idle connections
|
||||
pingTimeout: 55000,
|
||||
})
|
||||
ws.onopen = () => dispatch(setWebSocketConnected(true))
|
||||
ws.onclose = () => dispatch(setWebSocketConnected(false))
|
||||
ws.onmessage = event => {
|
||||
const { data } = event
|
||||
if (typeof data === "string") {
|
||||
if (data.startsWith("new-feed-entries:")) dispatch(reloadTree())
|
||||
if (websocketEnabled && websocketPingInterval) {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const wsProtocol = currentUrl.protocol === "http:" ? "ws" : "wss"
|
||||
const wsUrl = `${wsProtocol}://${currentUrl.hostname}:${currentUrl.port}${currentUrl.pathname}ws`
|
||||
|
||||
ws = new WebsocketHeartbeatJs({
|
||||
url: wsUrl,
|
||||
pingMsg: "ping",
|
||||
pingTimeout: websocketPingInterval,
|
||||
})
|
||||
ws.onopen = () => dispatch(setWebSocketConnected(true))
|
||||
ws.onclose = () => dispatch(setWebSocketConnected(false))
|
||||
ws.onmessage = event => {
|
||||
const { data } = event
|
||||
if (typeof data === "string") {
|
||||
handleMessage(dispatch, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => ws.close()
|
||||
}, [dispatch])
|
||||
return () => ws?.close()
|
||||
}, [dispatch, websocketEnabled, websocketPingInterval])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { i18n } from "@lingui/core"
|
||||
import { i18n, type Messages } from "@lingui/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import dayjs from "dayjs"
|
||||
import { useEffect } from "react"
|
||||
@@ -12,40 +12,40 @@ interface 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
|
||||
export const locales: Locale[] = [
|
||||
{ key: "ar", label: "العربية", daysjsImportFn: () => import("dayjs/locale/ar") },
|
||||
{ key: "ca", label: "Català", daysjsImportFn: () => import("dayjs/locale/ca") },
|
||||
{ key: "cs", label: "Čeština", daysjsImportFn: () => import("dayjs/locale/cs") },
|
||||
{ key: "cy", label: "Cymraeg", daysjsImportFn: () => import("dayjs/locale/cy") },
|
||||
{ key: "da", label: "Danish", daysjsImportFn: () => import("dayjs/locale/da") },
|
||||
{ key: "de", label: "Deutsch", daysjsImportFn: () => import("dayjs/locale/de") },
|
||||
{ key: "en", label: "English", daysjsImportFn: () => import("dayjs/locale/en") },
|
||||
{ key: "es", label: "Español", daysjsImportFn: () => import("dayjs/locale/es") },
|
||||
{ key: "fa", label: "فارسی", daysjsImportFn: () => import("dayjs/locale/fa") },
|
||||
{ key: "fi", label: "Suomi", daysjsImportFn: () => import("dayjs/locale/fi") },
|
||||
{ key: "fr", label: "Français", daysjsImportFn: () => import("dayjs/locale/fr") },
|
||||
{ key: "gl", label: "Galician", daysjsImportFn: () => import("dayjs/locale/gl") },
|
||||
{ key: "hu", label: "Magyar", daysjsImportFn: () => import("dayjs/locale/hu") },
|
||||
{ key: "id", label: "Indonesian", daysjsImportFn: () => import("dayjs/locale/id") },
|
||||
{ key: "it", label: "Italiano", daysjsImportFn: () => import("dayjs/locale/it") },
|
||||
{ key: "ja", label: "日本語", daysjsImportFn: () => import("dayjs/locale/ja") },
|
||||
{ key: "ko", label: "한국어", daysjsImportFn: () => import("dayjs/locale/ko") },
|
||||
{ key: "ms", label: "Bahasa Malaysian", daysjsImportFn: () => import("dayjs/locale/ms") },
|
||||
{ key: "nb", label: "Norsk (bokmål)", daysjsImportFn: () => import("dayjs/locale/nb") },
|
||||
{ key: "nl", label: "Nederlands", daysjsImportFn: () => import("dayjs/locale/nl") },
|
||||
{ key: "nn", label: "Norsk (nynorsk)", daysjsImportFn: () => import("dayjs/locale/nn") },
|
||||
{ key: "pl", label: "Polski", daysjsImportFn: () => import("dayjs/locale/pl") },
|
||||
{ key: "pt", label: "Português", daysjsImportFn: () => import("dayjs/locale/pt") },
|
||||
{ key: "ru", label: "Русский", daysjsImportFn: () => import("dayjs/locale/ru") },
|
||||
{ key: "sk", label: "Slovenčina", daysjsImportFn: () => import("dayjs/locale/sk") },
|
||||
{ key: "sv", label: "Svenska", daysjsImportFn: () => import("dayjs/locale/sv") },
|
||||
{ key: "tr", label: "Türkçe", daysjsImportFn: () => import("dayjs/locale/tr") },
|
||||
{ key: "zh", label: "简体中文", daysjsImportFn: () => import("dayjs/locale/zh") },
|
||||
{ key: "ar", label: "العربية", daysjsImportFn: async () => await import("dayjs/locale/ar") },
|
||||
{ key: "ca", label: "Català", daysjsImportFn: async () => await import("dayjs/locale/ca") },
|
||||
{ key: "cs", label: "Čeština", daysjsImportFn: async () => await import("dayjs/locale/cs") },
|
||||
{ key: "cy", label: "Cymraeg", daysjsImportFn: async () => await import("dayjs/locale/cy") },
|
||||
{ key: "da", label: "Danish", daysjsImportFn: async () => await import("dayjs/locale/da") },
|
||||
{ key: "de", label: "Deutsch", daysjsImportFn: async () => await import("dayjs/locale/de") },
|
||||
{ key: "en", label: "English", daysjsImportFn: async () => await import("dayjs/locale/en") },
|
||||
{ key: "es", label: "Español", daysjsImportFn: async () => await import("dayjs/locale/es") },
|
||||
{ key: "fa", label: "فارسی", daysjsImportFn: async () => await import("dayjs/locale/fa") },
|
||||
{ key: "fi", label: "Suomi", daysjsImportFn: async () => await import("dayjs/locale/fi") },
|
||||
{ key: "fr", label: "Français", daysjsImportFn: async () => await import("dayjs/locale/fr") },
|
||||
{ key: "gl", label: "Galician", daysjsImportFn: async () => await import("dayjs/locale/gl") },
|
||||
{ key: "hu", label: "Magyar", daysjsImportFn: async () => await import("dayjs/locale/hu") },
|
||||
{ key: "id", label: "Indonesian", daysjsImportFn: async () => await import("dayjs/locale/id") },
|
||||
{ key: "it", label: "Italiano", daysjsImportFn: async () => await import("dayjs/locale/it") },
|
||||
{ key: "ja", label: "日本語", daysjsImportFn: async () => await import("dayjs/locale/ja") },
|
||||
{ key: "ko", label: "한국어", daysjsImportFn: async () => await import("dayjs/locale/ko") },
|
||||
{ key: "ms", label: "Bahasa Malaysian", daysjsImportFn: async () => await import("dayjs/locale/ms") },
|
||||
{ key: "nb", label: "Norsk (bokmål)", daysjsImportFn: async () => await import("dayjs/locale/nb") },
|
||||
{ key: "nl", label: "Nederlands", daysjsImportFn: async () => await import("dayjs/locale/nl") },
|
||||
{ key: "nn", label: "Norsk (nynorsk)", daysjsImportFn: async () => await import("dayjs/locale/nn") },
|
||||
{ key: "pl", label: "Polski", daysjsImportFn: async () => await import("dayjs/locale/pl") },
|
||||
{ key: "pt", label: "Português", daysjsImportFn: async () => await import("dayjs/locale/pt") },
|
||||
{ key: "ru", label: "Русский", daysjsImportFn: async () => await import("dayjs/locale/ru") },
|
||||
{ key: "sk", label: "Slovenčina", daysjsImportFn: async () => await import("dayjs/locale/sk") },
|
||||
{ key: "sv", label: "Svenska", daysjsImportFn: async () => await import("dayjs/locale/sv") },
|
||||
{ key: "tr", label: "Türkçe", daysjsImportFn: async () => await import("dayjs/locale/tr") },
|
||||
{ key: "zh", label: "简体中文", daysjsImportFn: async () => await import("dayjs/locale/zh") },
|
||||
]
|
||||
|
||||
function activateLocale(locale: string) {
|
||||
// lingui
|
||||
import(`./locales/${locale}/messages.po`).then(data => {
|
||||
i18n.load(locale, data.messages)
|
||||
i18n.load(locale, data.messages as Messages)
|
||||
i18n.activate(locale)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "سيؤدي تغيير كلمة المرور إلى إنشاء مفتاح
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "تأكد من عمل الخلاصة"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "تأكيد كلمة المرور"
|
||||
msgid "Cozy"
|
||||
msgstr "دافئ"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "إنشاء علامة: {استعلام}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "السيطرة"
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "تاريخ الإنشاء"
|
||||
@@ -445,6 +445,10 @@ msgstr "التحديث الأخير"
|
||||
msgid "Last refresh message"
|
||||
msgstr "آخر رسالة تحديث"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "لم يتم العثور على شيء"
|
||||
msgid "Oldest first"
|
||||
msgstr "الأقدم أولا"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "اوووه!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "فتح الإدخال التالي"
|
||||
@@ -721,7 +733,7 @@ msgstr "ضع التركيز على الإدخال السابق دون فتحه"
|
||||
msgid "Settings"
|
||||
msgstr "إعدادات"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "تم حفظ الإعدادات."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "النجاح"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "التبديل إلى النسق الداكن"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "قم بالتبديل إلى النسق الفاتح"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "الكلمات"
|
||||
@@ -836,6 +851,10 @@ msgstr "عنوان URL للتغذية التي تريد الاشتراك فيه
|
||||
msgid "Theme"
|
||||
msgstr "الموضوع"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "تبديل قراءة حالة الإدخال الحالي"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Canviar la contrasenya generarà una nova clau d'API"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Comproveu que el canal funciona"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Confirmeu la contrasenya"
|
||||
msgid "Cozy"
|
||||
msgstr "Acollidor"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Crea una etiqueta: {consulta}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Data de creació"
|
||||
@@ -445,6 +445,10 @@ msgstr "Última actualització"
|
||||
msgid "Last refresh message"
|
||||
msgstr "últim missatge d'actualització"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "No s'ha trobat res"
|
||||
msgid "Oldest first"
|
||||
msgstr "el més vell primer"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Vaja!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Obre la següent entrada"
|
||||
@@ -721,7 +733,7 @@ msgstr "Estableix el focus en l'entrada anterior sense obrir-la"
|
||||
msgid "Settings"
|
||||
msgstr "Configuració"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Configuració desada."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Éxit"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Canvia al tema fosc"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Canvia al tema clar"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetes"
|
||||
@@ -836,6 +851,10 @@ msgstr "l'URL del canal al qual us voleu subscriure. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Canvia l'estat de lectura de l'entrada actual"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Změna hesla vygeneruje nový klíč API"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Zkontrolujte, zda zdroj funguje"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Potvrďte heslo"
|
||||
msgid "Cozy"
|
||||
msgstr "Útulný"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Vytvořit značku: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Datum vytvoření"
|
||||
@@ -445,6 +445,10 @@ msgstr "Poslední aktualizace"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Poslední obnovovací zpráva"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Nic nebylo nalezeno"
|
||||
msgid "Oldest first"
|
||||
msgstr "Nejdříve nejstarší"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Jejda!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Otevřete další položku"
|
||||
@@ -721,7 +733,7 @@ msgstr "Nastavit fokus na předchozí záznam bez jeho otevření"
|
||||
msgid "Settings"
|
||||
msgstr "Nastavení"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Nastavení uloženo."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Úspěch"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Přepněte na tmavý motiv"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Přepněte na světlé téma"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Značky"
|
||||
@@ -836,6 +851,10 @@ msgstr "Adresa URL kanálu, k jehož odběru se chcete přihlásit. "
|
||||
msgid "Theme"
|
||||
msgstr "Téma"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Přepne stav čtení aktuálního záznamu"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Bydd newid cyfrinair yn cynhyrchu allwedd API newydd"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Gwiriwch fod y porthiant yn gweithio"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Cadarnhau'r cyfrinair"
|
||||
msgid "Cozy"
|
||||
msgstr "clyd"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Creu tag: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Dyddiad creu"
|
||||
@@ -445,6 +445,10 @@ msgstr "adnewyddu diwethaf"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Neges adnewyddu ddiwethaf"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Dim wedi'i ddarganfod"
|
||||
msgid "Oldest first"
|
||||
msgstr "Hynaf yn gyntaf"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Wps!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Agor y cofnod nesaf"
|
||||
@@ -721,7 +733,7 @@ msgstr "Gosod ffocws ar gofnod blaenorol heb ei agor"
|
||||
msgid "Settings"
|
||||
msgstr "Gosodiadau"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Gosodiadau wedi'u cadw."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Llwyddiant"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Newid i thema dywyll"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Newid i thema golau"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Tagiau"
|
||||
@@ -836,6 +851,10 @@ msgstr "Y URL ar gyfer y porthwr rydych chi am danysgrifio iddo. "
|
||||
msgid "Theme"
|
||||
msgstr "Thema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Toglo statws darllen y cofnod cyfredol"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Ændring af adgangskode vil generere en ny API-nøgle"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Tjek, at foderet virker"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Bekræft adgangskode"
|
||||
msgid "Cozy"
|
||||
msgstr "Hyggeligt"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Opret tag: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Dato oprettet"
|
||||
@@ -445,6 +445,10 @@ msgstr "Sidste opdatering"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Sidste opdateringsmeddelelse"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Intet fundet"
|
||||
msgid "Oldest first"
|
||||
msgstr "Ældst først"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Hovsa!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Åbn næste post"
|
||||
@@ -721,7 +733,7 @@ msgstr "Sæt fokus på forrige indtastning uden at åbne den"
|
||||
msgid "Settings"
|
||||
msgstr "Indstillinger"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Indstillinger gemt."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Succes"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Skift til mørkt tema"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Skift til lystema"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
@@ -836,6 +851,10 @@ msgstr "URL'en til det feed, du vil abonnere på. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Skift læsestatus for den aktuelle post"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr "CommaFeed ist ein Open Source Projekt. Der Quellcode wird auf auf </0><1>GitHub</1> gehostet."
|
||||
@@ -175,13 +171,17 @@ msgstr "Das Ändern des Passworts generiert einen neuen API-Schlüssel"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Überprüfen Sie, ob der Feed funktioniert"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgstr "CommaFeed ist kompatibel zur Fever API. Benutzen Sie folgende URL in Ihrem Fever-kompatiblen Mobilclient. Der Benutzername ist Ihr User Name, das Passwort ist der API-Schlüssel."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
@@ -211,10 +211,6 @@ msgstr "Passwort bestätigen"
|
||||
msgid "Cozy"
|
||||
msgstr "Gemütlich"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Tag erstellen: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "Strg"
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Erstellungsdatum"
|
||||
@@ -445,6 +445,10 @@ msgstr "Letzte Aktualisierung"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Letzte Aktualisierungsmeldung"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Nichts gefunden"
|
||||
msgid "Oldest first"
|
||||
msgstr "Älteste zuerst"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Ups!"
|
||||
@@ -598,6 +606,10 @@ msgstr "Link in neuem Tab im Hintergrund öffnen"
|
||||
msgid "Open link in new tab"
|
||||
msgstr "Link in neuem Tab öffnen"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Nächsten Eintrag öffnen"
|
||||
@@ -721,7 +733,7 @@ msgstr "Fokus auf vorherigen Eintrag setzen, ohne ihn zu öffnen"
|
||||
msgid "Settings"
|
||||
msgstr "Einstellungen"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Einstellungen gespeichert."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Erfolg"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Zum Darkmode wechseln"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Zum Lightmode wechseln"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
@@ -836,6 +851,10 @@ msgstr "Die URL für den Feed, den Sie abonnieren möchten. "
|
||||
msgid "Theme"
|
||||
msgstr "Thema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Lesestatus des aktuellen Eintrags umschalten"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr "{0} (in {1})"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
@@ -175,13 +171,17 @@ msgstr "Changing password will generate a new API key"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Check that the feed is working"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr "Close menu"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgstr "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
@@ -211,10 +211,6 @@ msgstr "Confirm password"
|
||||
msgid "Cozy"
|
||||
msgstr "Cozy"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Create tag: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "Ctrl"
|
||||
@@ -235,6 +231,10 @@ msgstr "Custom CSS rules that will be applied"
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr "Custom JS code that will be executed on page load"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr "Dark"
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Date created"
|
||||
@@ -445,6 +445,10 @@ msgstr "Last refresh"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Last refresh message"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr "Light"
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Nothing found"
|
||||
msgid "Oldest first"
|
||||
msgstr "Oldest first"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr "On mobile, show action buttons at the bottom of the screen"
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Oops!"
|
||||
@@ -598,6 +606,10 @@ msgstr "Open link in new background tab"
|
||||
msgid "Open link in new tab"
|
||||
msgstr "Open link in new tab"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr "Open menu"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Open next entry"
|
||||
@@ -721,7 +733,7 @@ msgstr "Set focus on previous entry without opening it"
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Settings saved."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Success"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgstr "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr "Swipe header to the left"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Switch to dark theme"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Switch to light theme"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr "System"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Tags"
|
||||
@@ -836,6 +851,10 @@ msgstr "The URL for the feed you want to subscribe to. You can also use the webs
|
||||
msgid "Theme"
|
||||
msgstr "Theme"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr "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"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Toggle read status of current entry"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Cambiar la contraseña generará una nueva clave API"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Compruebe que el feed funciona"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Confirmar contraseña"
|
||||
msgid "Cozy"
|
||||
msgstr "Acogedor"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Crear etiqueta: {consulta}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Fecha de creación"
|
||||
@@ -445,6 +445,10 @@ msgstr "Última actualización"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Último mensaje de actualización"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Nada encontrado"
|
||||
msgid "Oldest first"
|
||||
msgstr "más antigua primero"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "¡Ups!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Abrir siguiente entrada"
|
||||
@@ -721,7 +733,7 @@ msgstr "Poner el foco en la entrada anterior sin abrirla"
|
||||
msgid "Settings"
|
||||
msgstr "Configuraciones"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Ajustes guardados."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Éxito"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Cambiar a tema oscuro"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Cambiar a tema claro"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetas"
|
||||
@@ -836,6 +851,10 @@ msgstr "La URL de la fuente a la que desea suscribirse. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Alternar estado de lectura de la entrada actual"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "تغییر رمز عبور یک کلید API جدید ایجاد می ک
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "بررسی کنید که خوراک کار می کند"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "رمز عبور را تأیید کنید"
|
||||
msgid "Cozy"
|
||||
msgstr "دنج"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "ایجاد برچسب: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "تاریخ ایجاد"
|
||||
@@ -445,6 +445,10 @@ msgstr "آخرین به روز رسانی"
|
||||
msgid "Last refresh message"
|
||||
msgstr "آخرین پیام تازه کردن"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "چیزی پیدا نشد"
|
||||
msgid "Oldest first"
|
||||
msgstr "قدیمی ترین اول"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "اوه!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "ورودی بعدی را باز کنید"
|
||||
@@ -721,7 +733,7 @@ msgstr "فوکوس را روی ورودی قبلی بدون باز کردن آن
|
||||
msgid "Settings"
|
||||
msgstr "تنظیمات"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "تنظیمات ذخیره شد."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "موفقیت"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "تغییر به تم تیره"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "روی زمینه روشن"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "برچسب ها"
|
||||
@@ -836,6 +851,10 @@ msgstr "URL فیدی که می خواهید در آن مشترک شوید. "
|
||||
msgid "Theme"
|
||||
msgstr "تم"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "وضعیت خواندن ورودی فعلی را تغییر دهید"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Salasanan vaihtaminen luo uuden API-avaimen"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Tarkista, että syöttö toimii"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Vahvista salasana"
|
||||
msgid "Cozy"
|
||||
msgstr "Viihtyisä"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Luo tunniste: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Luontipäivämäärä"
|
||||
@@ -445,6 +445,10 @@ msgstr "Viimeinen päivitys"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Viimeinen päivitysviesti"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Mitään ei löytynyt"
|
||||
msgid "Oldest first"
|
||||
msgstr "Vanhin ensin"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Hups!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Avaa seuraava merkintä"
|
||||
@@ -721,7 +733,7 @@ msgstr "Aseta kohdistus edelliseen merkintään avaamatta sitä"
|
||||
msgid "Settings"
|
||||
msgstr "Asetukset"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Asetukset tallennettu."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Onnistui"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Vaihda tummaan teemaan"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Vaihda vaaleaan teemaan"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
@@ -836,6 +851,10 @@ msgstr "Sen syötteen URL-osoite, jonka haluat tilata. "
|
||||
msgid "Theme"
|
||||
msgstr "Teema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Vaihda nykyisen merkinnän lukutila"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr "{0} (sur {1})"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr "<0>CommaFeed est un projet open-source. Les sources sont hébergées sur </0><1>GitHub</1>."
|
||||
@@ -175,13 +171,17 @@ msgstr "Changer de mot de passe générera une nouvelle clé API"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Vérifie que le flux fonctionne"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr "Fermer le menu"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr "Extension CommaFeed pour navigateur version {browserExtensionVersion}."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgstr "Commafeed est compatible avec l'API Fever, en inscrivant l'URL suivante dans votre client mobile compatible. Entrez votre nom d'utilisateur habituel, et votre clef API comme mot de passe."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "Commafeed est compatible avec l'API Fever, en inscrivant l'URL suivante dans votre client mobile compatible. Entrez votre nom d'utilisateur habituel, et votre <0>clef API</0> comme mot de passe."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
@@ -211,10 +211,6 @@ msgstr "Confirmer le mot de passe"
|
||||
msgid "Cozy"
|
||||
msgstr "Cozy"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Créer le marqueur : {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "Ctrl"
|
||||
@@ -235,6 +231,10 @@ msgstr "Code CSS personnalisé qui sera appliqué"
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr "Code JS personnalisé qui sera appliqué au chargement des pages"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr "Foncé"
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Date de création"
|
||||
@@ -445,6 +445,10 @@ msgstr "Dernière mise à jour"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Dernier message de mise à jour"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr "Clair"
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Aucun résultat"
|
||||
msgid "Oldest first"
|
||||
msgstr "Du plus ancien au plus récent"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Oups !"
|
||||
@@ -598,6 +606,10 @@ msgstr "Ouvrir le lien dans un nouvel onglet en arrière-plan"
|
||||
msgid "Open link in new tab"
|
||||
msgstr "Ouvrir le lien dans un nouvel onglet"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr "Ouvrir le menu"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Ouvrir l'entrée suivante"
|
||||
@@ -721,7 +733,7 @@ msgstr "Sélectionner l'article précédent sans l'ouvrir"
|
||||
msgid "Settings"
|
||||
msgstr "Réglages"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Réglages enregistrés."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Succès"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgstr "Faire glisser le titre vers la droite"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr "Faire glisser le titre vers la gauche"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Activer le mode sombre"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Activer le mode clair"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr "Système"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Marqueurs"
|
||||
@@ -836,6 +851,10 @@ msgstr "L'URL du flux auquel vous souhaitez vous abonner. Vous pouvez aussi util
|
||||
msgid "Theme"
|
||||
msgstr "Thème"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr "Ceci est votre clef API. Elle peut être utilisée pour certaines opérations en lecture seule et donne accès à l'API Fever. Utilisez le formulaire en bas de la page pour générer une nouvelle clef API"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Marquer l'entrée actuelle comme lue/non lue"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "O cambio de contrasinal xerará unha nova clave de API"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Comproba que a fonte funciona"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Confirmar contrasinal"
|
||||
msgid "Cozy"
|
||||
msgstr "acolledor"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Crear etiqueta: {consulta}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Data de creación"
|
||||
@@ -445,6 +445,10 @@ msgstr "Última actualización"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Última mensaxe de actualización"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Non se atopou nada"
|
||||
msgid "Oldest first"
|
||||
msgstr "O máis vello primeiro"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Vaia!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Abrir a seguinte entrada"
|
||||
@@ -721,7 +733,7 @@ msgstr "Establecer o foco na entrada anterior sen abrila"
|
||||
msgid "Settings"
|
||||
msgstr "Configuración"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "axustes gardados."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Éxito"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Cambiar ao tema escuro"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Cambiar ao tema claro"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetas"
|
||||
@@ -836,6 +851,10 @@ msgstr "O URL do feed ao que quere subscribirse. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "alternar o estado de lectura da entrada actual"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "A jelszó megváltoztatása új API-kulcsot generál"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Ellenőrizze, hogy a feed működik-e"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Erősítse meg a jelszót"
|
||||
msgid "Cozy"
|
||||
msgstr "Hangulatos"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Címke létrehozása: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Létrehozás dátuma"
|
||||
@@ -445,6 +445,10 @@ msgstr "Utolsó frissítés"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Utolsó frissítési üzenet"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Semmi sem található"
|
||||
msgid "Oldest first"
|
||||
msgstr "A legidősebb első"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Hoppá!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Következő bejegyzés megnyitása"
|
||||
@@ -721,7 +733,7 @@ msgstr "Állítsa a fókuszt az előző bejegyzésre anélkül, hogy megnyitná
|
||||
msgid "Settings"
|
||||
msgstr "Beállítások"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Beállítások mentve."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Siker"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Váltás sötét témára"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Váltás világos témára"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Címkék"
|
||||
@@ -836,6 +851,10 @@ msgstr "Az előfizetni kívánt hírcsatorna URL-je. "
|
||||
msgid "Theme"
|
||||
msgstr "Téma"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Az aktuális bejegyzés olvasási állapotának váltása"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Mengubah kata sandi akan menghasilkan kunci API baru"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Periksa apakah umpannya berfungsi"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Konfirmasi kata sandi"
|
||||
msgid "Cozy"
|
||||
msgstr "Nyaman"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Buat tag: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Tanggal dibuat"
|
||||
@@ -445,6 +445,10 @@ msgstr "Penyegaran terakhir"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Pesan penyegaran terakhir"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Tidak ada yang ditemukan"
|
||||
msgid "Oldest first"
|
||||
msgstr "Tertua dulu"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Ups!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Buka entri berikutnya"
|
||||
@@ -721,7 +733,7 @@ msgstr "Tetapkan fokus pada entri sebelumnya tanpa membukanya"
|
||||
msgid "Settings"
|
||||
msgstr "Pengaturan"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Pengaturan disimpan."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Sukses"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Beralih ke tema gelap"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Beralih ke tema terang"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Tag"
|
||||
@@ -836,6 +851,10 @@ msgstr "URL untuk umpan yang ingin Anda langgani. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Beralih status baca entri saat ini"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "La modifica della password genererà una nuova chiave API"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Verifica che il feed funzioni"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Conferma password"
|
||||
msgid "Cozy"
|
||||
msgstr "Accogliente"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Crea tag: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "ctrl"
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Data di creazione"
|
||||
@@ -445,6 +445,10 @@ msgstr "Ultimo aggiornamento"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Ultimo messaggio di aggiornamento"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Non è stato trovato nulla"
|
||||
msgid "Oldest first"
|
||||
msgstr "Il più vecchio prima"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Ops!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Apri voce successiva"
|
||||
@@ -721,7 +733,7 @@ msgstr "Imposta il focus sulla voce precedente senza aprirla"
|
||||
msgid "Settings"
|
||||
msgstr "Impostazioni"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Impostazioni salvate."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Successo"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Passa al tema scuro"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Passa al tema della luce"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Tag"
|
||||
@@ -836,6 +851,10 @@ msgstr "L'URL del feed a cui vuoi iscriverti. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Commuta lo stato di lettura della voce corrente"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "パスワードを変更すると、新しい API キーが生成され
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "フィードが動作していることを確認してください"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "パスワード確認"
|
||||
msgid "Cozy"
|
||||
msgstr "コージー"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "タグを作成: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "コントロール"
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "作成日"
|
||||
@@ -445,6 +445,10 @@ msgstr "最終更新"
|
||||
msgid "Last refresh message"
|
||||
msgstr "最終更新メッセージ"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "何も見つかりませんでした"
|
||||
msgid "Oldest first"
|
||||
msgstr "古い順"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "おっと!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "次のエントリを開く"
|
||||
@@ -721,7 +733,7 @@ msgstr "前のエントリを開かずにフォーカスを設定する"
|
||||
msgid "Settings"
|
||||
msgstr "設定"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "設定が保存されました。"
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "成功"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "ダークテーマに切り替え"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "ライトテーマに切り替え"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "タグ"
|
||||
@@ -836,6 +851,10 @@ msgstr "購読したいフィードのURL。 "
|
||||
msgid "Theme"
|
||||
msgstr "テーマ"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "現在のエントリの読み取りステータスを切り替えます"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "비밀번호를 변경하면 새 API 키가 생성됩니다."
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "피드가 작동하는지 확인"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "비밀번호 확인"
|
||||
msgid "Cozy"
|
||||
msgstr "코지"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "태그 생성: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "컨트롤"
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "생성 날짜"
|
||||
@@ -445,6 +445,10 @@ msgstr "마지막 새로 고침"
|
||||
msgid "Last refresh message"
|
||||
msgstr "마지막 새로고침 메시지"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "아무것도 찾을 수 없습니다"
|
||||
msgid "Oldest first"
|
||||
msgstr "가장 오래된 것부터"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "앗!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "다음 항목 열기"
|
||||
@@ -721,7 +733,7 @@ msgstr "이전 항목을 열지 않고 포커스 설정"
|
||||
msgid "Settings"
|
||||
msgstr "설정"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "설정이 저장되었습니다."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "성공"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "어두운 테마로 전환"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "밝은 테마로 전환"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "태그"
|
||||
@@ -836,6 +851,10 @@ msgstr "구독하려는 피드의 URL입니다. "
|
||||
msgid "Theme"
|
||||
msgstr "테마"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "현재 항목의 읽기 상태 전환"
|
||||
|
||||
@@ -13,10 +13,6 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
msgid "{0} (in {1})"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
@@ -175,12 +171,16 @@ msgstr "Menukar kata laluan akan menjana kunci API baharu"
|
||||
msgid "Check that the feed is working"
|
||||
msgstr "Semak sama ada suapan berfungsi"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. The username is your user name and the password is your API key."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
@@ -211,10 +211,6 @@ msgstr "Sahkan kata laluan"
|
||||
msgid "Cozy"
|
||||
msgstr "Nyaman"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Create tag: {query}"
|
||||
msgstr "Cipta teg: {query}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr ""
|
||||
@@ -235,6 +231,10 @@ msgstr ""
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "Tarikh dibuat"
|
||||
@@ -445,6 +445,10 @@ msgstr "Muat semula terakhir"
|
||||
msgid "Last refresh message"
|
||||
msgstr "Mesej muat semula terakhir"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
#: src/pages/app/TagDetailsPage.tsx
|
||||
@@ -570,6 +574,10 @@ msgstr "Tiada apa-apa dijumpai"
|
||||
msgid "Oldest first"
|
||||
msgstr "Tertua dahulu"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "Aduh!"
|
||||
@@ -598,6 +606,10 @@ msgstr ""
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
msgstr "Buka entri seterusnya"
|
||||
@@ -721,7 +733,7 @@ msgstr "Tetapkan fokus pada entri sebelumnya tanpa membukanya"
|
||||
msgid "Settings"
|
||||
msgstr "Tetapan"
|
||||
|
||||
#: src/app/slices/user.ts
|
||||
#: src/app/user/slice.ts
|
||||
msgid "Settings saved."
|
||||
msgstr "Tetapan disimpan."
|
||||
|
||||
@@ -811,19 +823,22 @@ msgid "Success"
|
||||
msgstr "Kejayaan"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the right"
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
msgstr "Tukar kepada tema gelap"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to light theme"
|
||||
msgstr "Tukar kepada tema cahaya"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "Tag"
|
||||
@@ -836,6 +851,10 @@ msgstr "URL untuk suapan yang anda ingin langgan. "
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "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"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
msgstr "Togol status bacaan entri semasa"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user