mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-06 18:03:14 +08:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573b6f5411 | ||
|
|
460b435110 | ||
|
|
dfbba20275 | ||
|
|
f67c4605fd | ||
|
|
1f0a2201ba | ||
|
|
d52caefe2c | ||
|
|
c998dd35b7 | ||
|
|
397eb6d316 | ||
|
|
fbb9e10421 | ||
|
|
b937f26b44 | ||
|
|
414c7abbe5 | ||
|
|
7333b283cf | ||
|
|
4f26c3483b | ||
|
|
184e4baa84 | ||
|
|
1b90db70c0 | ||
|
|
cbc8618805 | ||
|
|
afbf85b249 | ||
|
|
9ab91e018b | ||
|
|
f7229a06fc | ||
|
|
5f5ea73aec | ||
|
|
fdb5b4e82d | ||
|
|
50180c9684 | ||
|
|
65f3759444 | ||
|
|
7dfb8126c4 | ||
|
|
5da450ce96 | ||
|
|
3b26190df5 | ||
|
|
4d82fefa4e | ||
|
|
06048ee26f | ||
|
|
4f5009b30f | ||
|
|
ee169e82cd | ||
|
|
cce191440d | ||
|
|
ec0c7ee253 | ||
|
|
4291bda9d4 | ||
|
|
f7c371bce1 | ||
|
|
b6c4634403 | ||
|
|
b4a5b34815 | ||
|
|
ffc1a69085 | ||
|
|
38d450a916 | ||
|
|
df595f2219 | ||
|
|
b82d07c588 | ||
|
|
fc15363349 | ||
|
|
b97b0493e0 | ||
|
|
9013a8dfc2 | ||
|
|
4fed5de7b3 | ||
|
|
ee1cf6596b | ||
|
|
12dd1d8bc6 | ||
|
|
74ddf45632 | ||
|
|
83092ccb48 | ||
|
|
492de5618c | ||
|
|
c349ad7670 | ||
|
|
1c17e16655 | ||
|
|
9b70bc3b55 | ||
|
|
beba4f8b93 | ||
|
|
bb7af56dc1 | ||
|
|
e89fecbd10 | ||
|
|
70734ed273 | ||
|
|
dcb15f1942 | ||
|
|
3b6cdbdd84 | ||
|
|
344420ec4a | ||
|
|
eb99ece360 | ||
|
|
95529eccd4 | ||
|
|
a6b36750da | ||
|
|
8b98a335d4 | ||
|
|
6ac8ce6a7b | ||
|
|
a9f135552a | ||
|
|
f110eb35fe | ||
|
|
051bd39256 | ||
|
|
229d3b511f | ||
|
|
b9d6d91a91 | ||
|
|
a7a4dd5fff | ||
|
|
ecb34d2aea | ||
|
|
5495565fbd | ||
|
|
0c18b83a8e |
@@ -2,7 +2,7 @@
|
|||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||||
{
|
{
|
||||||
"name": "Python 3",
|
"name": "Python 3",
|
||||||
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
"image": "mcr.microsoft.com/devcontainers/python:3.13",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:1": {}
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
},
|
},
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"forwardPorts": [8000],
|
"forwardPorts": [8000],
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
"postCreateCommand": "pip install uv && uv sync --group dev && npm install && mkdir -p data && uv run manage.py migrate",
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
"customizations": {
|
"customizations": {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
!/postcss.config.js
|
!/postcss.config.js
|
||||||
!/pyproject.toml
|
!/pyproject.toml
|
||||||
!/rollup.config.mjs
|
!/rollup.config.mjs
|
||||||
!/supervisord.conf
|
!/supervisord-tasks.conf
|
||||||
|
!/supervisord-all.conf
|
||||||
!/uv.lock
|
!/uv.lock
|
||||||
!/uwsgi.ini
|
!/uwsgi.ini
|
||||||
!/version.txt
|
!/version.txt
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ LD_AUTH_PROXY_USERNAME_HEADER=
|
|||||||
# The URL that linkding should redirect to after a logout, when using an auth proxy
|
# The URL that linkding should redirect to after a logout, when using an auth proxy
|
||||||
# See docs/Options.md for more details
|
# See docs/Options.md for more details
|
||||||
LD_AUTH_PROXY_LOGOUT_URL=
|
LD_AUTH_PROXY_LOGOUT_URL=
|
||||||
|
# Disables the login form, useful to enforce OIDC authentication
|
||||||
|
LD_DISABLE_LOGIN_FORM=False
|
||||||
# List of trusted origins from which to accept POST requests
|
# List of trusted origins from which to accept POST requests
|
||||||
# See docs/Options.md for more details
|
# See docs/Options.md for more details
|
||||||
LD_CSRF_TRUSTED_ORIGINS=
|
LD_CSRF_TRUSTED_ORIGINS=
|
||||||
|
|||||||
8
.github/workflows/build-test.yaml
vendored
8
.github/workflows/build-test.yaml
vendored
@@ -38,6 +38,8 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:test
|
ghcr.io/sissbruecker/linkding:test
|
||||||
target: linkding
|
target: linkding
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build latest-alpine
|
- name: Build latest-alpine
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -49,6 +51,8 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:test-alpine
|
ghcr.io/sissbruecker/linkding:test-alpine
|
||||||
target: linkding
|
target: linkding
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||||
|
|
||||||
- name: Build latest-plus
|
- name: Build latest-plus
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -60,6 +64,8 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:test-plus
|
ghcr.io/sissbruecker/linkding:test-plus
|
||||||
target: linkding-plus
|
target: linkding-plus
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build latest-plus-alpine
|
- name: Build latest-plus-alpine
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -71,3 +77,5 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:test-plus-alpine
|
ghcr.io/sissbruecker/linkding:test-plus-alpine
|
||||||
target: linkding-plus
|
target: linkding-plus
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||||
|
|||||||
8
.github/workflows/build.yaml
vendored
8
.github/workflows/build.yaml
vendored
@@ -45,6 +45,8 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
|
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
|
||||||
target: linkding
|
target: linkding
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build latest-alpine
|
- name: Build latest-alpine
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -59,6 +61,8 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
|
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||||
target: linkding
|
target: linkding
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||||
|
|
||||||
- name: Build latest-plus
|
- name: Build latest-plus
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -73,6 +77,8 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
|
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||||
target: linkding-plus
|
target: linkding-plus
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build latest-plus-alpine
|
- name: Build latest-plus-alpine
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -87,3 +93,5 @@ jobs:
|
|||||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||||
target: linkding-plus
|
target: linkding-plus
|
||||||
push: true
|
push: true
|
||||||
|
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||||
|
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||||
10
.github/workflows/main.yaml
vendored
10
.github/workflows/main.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
uv sync
|
uv sync
|
||||||
mkdir data
|
mkdir data
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run manage.py test bookmarks.tests
|
run: uv run pytest -n auto
|
||||||
e2e_tests:
|
e2e_tests:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -59,4 +59,10 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
uv run manage.py collectstatic
|
uv run manage.py collectstatic
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
run: uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
|
||||||
|
- name: Upload screenshots
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-screenshots
|
||||||
|
path: test-results/screenshots
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -60,6 +60,7 @@ coverage.xml
|
|||||||
*.cover
|
*.cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
test-results/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|||||||
138
CHANGELOG.md
138
CHANGELOG.md
@@ -1,5 +1,143 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.45.0 (06/01/2026)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* API token management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1248
|
||||||
|
* Add option to disable login form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1269
|
||||||
|
* Turn scheme-less URLs into HTTPS instead of HTTP links by @Maaxxs in https://github.com/sissbruecker/linkding/pull/1225
|
||||||
|
* Disable bulk execute button when no bookmarks selected by @emanuelebeffa in https://github.com/sissbruecker/linkding/pull/1241
|
||||||
|
* Add option to run supervisor as main process by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1270
|
||||||
|
* Allow setting date_added and date_modified for new bookmarks through REST API by @jmason in https://github.com/sissbruecker/linkding/pull/1063
|
||||||
|
* Download PDF instead of creating HTML snapshot if URL points at PDF by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1271
|
||||||
|
* Allow sandboxed scripts when viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1252
|
||||||
|
* Allow viewing video assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1259
|
||||||
|
* Remove absolute URIs from settings page by @packrat386 in https://github.com/sissbruecker/linkding/pull/1261
|
||||||
|
* Move tag management forms into dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1253
|
||||||
|
* Move bulk edit checkboxes into bookmark list container by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1257
|
||||||
|
* Remove registration switch by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1268
|
||||||
|
* Add linkdinger to community projects by @lmmendes in https://github.com/sissbruecker/linkding/pull/1266
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @packrat386 made their first contribution in https://github.com/sissbruecker/linkding/pull/1261
|
||||||
|
* @lmmendes made their first contribution in https://github.com/sissbruecker/linkding/pull/1266
|
||||||
|
* @Maaxxs made their first contribution in https://github.com/sissbruecker/linkding/pull/1225
|
||||||
|
* @emanuelebeffa made their first contribution in https://github.com/sissbruecker/linkding/pull/1241
|
||||||
|
* @jmason made their first contribution in https://github.com/sissbruecker/linkding/pull/1063
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.2...v1.45.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.44.2 (13/12/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> *This resolves a [security vulnerability](https://github.com/sissbruecker/linkding/security/advisories/GHSA-3pf9-5cjv-2w7q) in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
|
||||||
|
|
||||||
|
* Use sandbox CSP for viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1245
|
||||||
|
* Fix devcontainer by @m3eno in https://github.com/sissbruecker/linkding/pull/1208
|
||||||
|
* Fix tag cloud highlighting first char when tags are not grouped by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1209
|
||||||
|
* Bump supervisor to 4.3.0 to fix warning by @simonhammes in https://github.com/sissbruecker/linkding/pull/1216
|
||||||
|
* Added Javascript client and library for Linkding REST API by @vbsampath in https://github.com/sissbruecker/linkding/pull/1195
|
||||||
|
* Add Komrade project to community resources by @dev-inside in https://github.com/sissbruecker/linkding/pull/1236
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @m3eno made their first contribution in https://github.com/sissbruecker/linkding/pull/1208
|
||||||
|
* @vbsampath made their first contribution in https://github.com/sissbruecker/linkding/pull/1195
|
||||||
|
* @dev-inside made their first contribution in https://github.com/sissbruecker/linkding/pull/1236
|
||||||
|
* @simonhammes made their first contribution in https://github.com/sissbruecker/linkding/pull/1216
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.1...v1.44.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.44.1 (11/10/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix normalized URL not being generated in bookmark import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1202
|
||||||
|
* Fix missing tags causing errors in import with Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1203
|
||||||
|
* Check for dupes by exact URL if normalized URL is missing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1204
|
||||||
|
* Attempt to fix botched normalized URL migration from 1.43.0 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1205
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.0...v1.44.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.44.0 (05/10/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add new search engine that supports logical expressions (and, or, not) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1198
|
||||||
|
* Fix pagination links to use relative URLs by @dunlor in https://github.com/sissbruecker/linkding/pull/1186
|
||||||
|
* Fix queued tasks link when context path is used by @dunlor in https://github.com/sissbruecker/linkding/pull/1187
|
||||||
|
* Fix bundle preview pagination resetting to first page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1194
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @dunlor made their first contribution in https://github.com/sissbruecker/linkding/pull/1186
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.43.0...v1.44.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.43.0 (28/09/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add basic tag management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1175
|
||||||
|
* Normalize URLs when checking for duplicates by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1169
|
||||||
|
* Add option to mark bookmarks as shared by default by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1170
|
||||||
|
* Use modal dialog for confirming actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1168
|
||||||
|
* Fix error when filtering bookmark assets in the admin UI by @proog in https://github.com/sissbruecker/linkding/pull/1162
|
||||||
|
* Document API bundle filter by @proog in https://github.com/sissbruecker/linkding/pull/1161
|
||||||
|
* Add alfred-linkding-bookmarks to community.md by @FireFingers21 in https://github.com/sissbruecker/linkding/pull/1160
|
||||||
|
* Switch to uv by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1172
|
||||||
|
* Replace Svelte components with Lit elements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1174
|
||||||
|
* Bump versions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1173
|
||||||
|
* Bump astro from 5.12.8 to 5.13.2 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1166
|
||||||
|
* Bump vite from 6.3.5 to 6.3.6 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1184
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @FireFingers21 made their first contribution in https://github.com/sissbruecker/linkding/pull/1160
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.42.0...v1.43.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.42.0 (16/08/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Bulk create HTML snapshots by @Tql-ws1 in https://github.com/sissbruecker/linkding/pull/1132
|
||||||
|
* Create bundle from current search query by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1154
|
||||||
|
* Add alternative bookmarklet that uses browser metadata by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1159
|
||||||
|
* Add date and time to HTML export filename by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1101
|
||||||
|
* Automatically compress uploads with gzip by @hkclark in https://github.com/sissbruecker/linkding/pull/1087
|
||||||
|
* Show bookmark bundles in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1110
|
||||||
|
* Allow filtering feeds by bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1152
|
||||||
|
* Submit bookmark form with Ctrl/Cmd + Enter by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1158
|
||||||
|
* Improve bookmark form accessibility by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1116
|
||||||
|
* Fix custom CSS not being used in reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1102
|
||||||
|
* Use filename when downloading asset through UI by @proog in https://github.com/sissbruecker/linkding/pull/1146
|
||||||
|
* Update order when deleting bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1114
|
||||||
|
* Wrap long titles in bookmark details modal by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1150
|
||||||
|
* Ignore tags with just whitespace by @pvl in https://github.com/sissbruecker/linkding/pull/1125
|
||||||
|
* Ignore tags that exceed length limit during import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1153
|
||||||
|
* Add CloudBreak on Managed Hosting by @benjaminoakes in https://github.com/sissbruecker/linkding/pull/1079
|
||||||
|
* Add Pocket migration to to community page by @hkclark in https://github.com/sissbruecker/linkding/pull/1112
|
||||||
|
* Add linkding-media-archiver to community.md by @proog in https://github.com/sissbruecker/linkding/pull/1144
|
||||||
|
* Bump astro from 5.7.13 to 5.12.8 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1147
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @hkclark made their first contribution in https://github.com/sissbruecker/linkding/pull/1087
|
||||||
|
* @benjaminoakes made their first contribution in https://github.com/sissbruecker/linkding/pull/1079
|
||||||
|
* @proog made their first contribution in https://github.com/sissbruecker/linkding/pull/1146
|
||||||
|
* @pvl made their first contribution in https://github.com/sissbruecker/linkding/pull/1125
|
||||||
|
* @Tql-ws1 made their first contribution in https://github.com/sissbruecker/linkding/pull/1132
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.41.0...v1.42.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.41.0 (19/06/2025)
|
## v1.41.0 (19/06/2025)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
init:
|
init:
|
||||||
uv sync
|
uv sync
|
||||||
|
[ -d data ] || mkdir data data/assets data/favicons data/previews
|
||||||
uv run manage.py migrate
|
uv run manage.py migrate
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
@@ -14,10 +15,24 @@ tasks:
|
|||||||
test:
|
test:
|
||||||
uv run pytest -n auto
|
uv run pytest -n auto
|
||||||
|
|
||||||
|
lint:
|
||||||
|
uv run ruff check bookmarks
|
||||||
|
|
||||||
format:
|
format:
|
||||||
uv run black bookmarks
|
uv run ruff format bookmarks
|
||||||
|
uv run djlint bookmarks/templates --reformat --quiet --warn
|
||||||
npx prettier bookmarks/frontend --write
|
npx prettier bookmarks/frontend --write
|
||||||
npx prettier bookmarks/styles --write
|
npx prettier bookmarks/styles --write
|
||||||
|
|
||||||
|
prepare-e2e:
|
||||||
|
uv run playwright install chromium
|
||||||
|
rm -rf static
|
||||||
|
npm run build
|
||||||
|
uv run manage.py collectstatic --no-input
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
make prepare-e2e
|
||||||
|
uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
npm run dev
|
npm run dev
|
||||||
21
README.md
21
README.md
@@ -96,34 +96,37 @@ Run all tests with pytest:
|
|||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
Run linting with ruff:
|
||||||
|
```
|
||||||
|
make lint
|
||||||
|
```
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
|
|
||||||
Format Python code with black, and JavaScript code with prettier:
|
Format Python code with ruff, Django templates with djlint, and JavaScript code with prettier:
|
||||||
```
|
```
|
||||||
make format
|
make format
|
||||||
```
|
```
|
||||||
|
|
||||||
### DevContainers
|
### DevContainers
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> The dev container setup is currently broken after switching to uv.
|
|
||||||
> Feel free to contribute a PR if you want to fix it.
|
|
||||||
> The instructions below are outdated until then.
|
|
||||||
|
|
||||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
||||||
|
|
||||||
Once checked out, only the following commands are required to get started:
|
Once checked out, only the following commands are required to get started:
|
||||||
|
|
||||||
Create a user for the frontend:
|
Create a user for the frontend:
|
||||||
```
|
```
|
||||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
uv run manage.py createsuperuser --username=joe --email=joe@example.com
|
||||||
```
|
```
|
||||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||||
```
|
```
|
||||||
npm run dev
|
make frontend
|
||||||
```
|
```
|
||||||
Start the Django development server with:
|
Start the Django development server with:
|
||||||
```
|
```
|
||||||
python3 manage.py runserver
|
make serve
|
||||||
```
|
```
|
||||||
The frontend is now available under http://localhost:8000
|
The frontend is now available under http://localhost:8000
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django import forms
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.contrib.admin import AdminSite
|
from django.contrib.admin import AdminSite
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
@@ -6,19 +9,18 @@ from django.core.paginator import Paginator
|
|||||||
from django.db.models import Count, QuerySet
|
from django.db.models import Count, QuerySet
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.utils.translation import ngettext, gettext
|
from django.utils.translation import gettext, ngettext
|
||||||
from huey.contrib.djhuey import HUEY as huey
|
from huey.contrib.djhuey import HUEY as huey
|
||||||
from rest_framework.authtoken.admin import TokenAdmin
|
|
||||||
from rest_framework.authtoken.models import TokenProxy
|
|
||||||
|
|
||||||
from bookmarks.models import (
|
from bookmarks.models import (
|
||||||
|
ApiToken,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
BookmarkAsset,
|
BookmarkAsset,
|
||||||
BookmarkBundle,
|
BookmarkBundle,
|
||||||
Tag,
|
|
||||||
UserProfile,
|
|
||||||
Toast,
|
|
||||||
FeedToken,
|
FeedToken,
|
||||||
|
Tag,
|
||||||
|
Toast,
|
||||||
|
UserProfile,
|
||||||
)
|
)
|
||||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||||
|
|
||||||
@@ -44,12 +46,14 @@ class TaskPaginator(Paginator):
|
|||||||
|
|
||||||
# Copied from Huey's SqliteStorage with some modifications to allow pagination
|
# Copied from Huey's SqliteStorage with some modifications to allow pagination
|
||||||
def enqueued_items(self, limit, offset):
|
def enqueued_items(self, limit, offset):
|
||||||
to_bytes = lambda b: bytes(b) if not isinstance(b, bytes) else b
|
def to_bytes(b):
|
||||||
|
return bytes(b) if not isinstance(b, bytes) else b
|
||||||
|
|
||||||
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
|
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
|
||||||
params = (huey.storage.name, limit, offset)
|
params = (huey.storage.name, limit, offset)
|
||||||
|
|
||||||
serialized_tasks = [
|
serialized_tasks = [
|
||||||
to_bytes(i) for i, in huey.storage.sql(sql, params, results=True)
|
to_bytes(i) for (i,) in huey.storage.sql(sql, params, results=True)
|
||||||
]
|
]
|
||||||
return [huey.deserialize_task(task) for task in serialized_tasks]
|
return [huey.deserialize_task(task) for task in serialized_tasks]
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ class LinkdingAdminSite(AdminSite):
|
|||||||
|
|
||||||
def get_app_list(self, request, app_label=None):
|
def get_app_list(self, request, app_label=None):
|
||||||
app_list = super().get_app_list(request, app_label)
|
app_list = super().get_app_list(request, app_label)
|
||||||
|
context_path = os.getenv("LD_CONTEXT_PATH", "")
|
||||||
app_list += [
|
app_list += [
|
||||||
{
|
{
|
||||||
"name": "Huey",
|
"name": "Huey",
|
||||||
@@ -91,7 +96,7 @@ class LinkdingAdminSite(AdminSite):
|
|||||||
{
|
{
|
||||||
"name": "Queued tasks",
|
"name": "Queued tasks",
|
||||||
"object_name": "background_tasks",
|
"object_name": "background_tasks",
|
||||||
"admin_url": "/admin/tasks/",
|
"admin_url": f"/{context_path}admin/tasks/",
|
||||||
"view_only": True,
|
"view_only": True,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -273,6 +278,8 @@ class AdminBookmarkBundle(admin.ModelAdmin):
|
|||||||
"any_tags",
|
"any_tags",
|
||||||
"all_tags",
|
"all_tags",
|
||||||
"excluded_tags",
|
"excluded_tags",
|
||||||
|
"filter_shared",
|
||||||
|
"filter_unread",
|
||||||
"date_created",
|
"date_created",
|
||||||
)
|
)
|
||||||
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||||
@@ -293,7 +300,7 @@ class AdminCustomUser(UserAdmin):
|
|||||||
def get_inline_instances(self, request, obj=None):
|
def get_inline_instances(self, request, obj=None):
|
||||||
if not obj:
|
if not obj:
|
||||||
return list()
|
return list()
|
||||||
return super(AdminCustomUser, self).get_inline_instances(request, obj)
|
return super().get_inline_instances(request, obj)
|
||||||
|
|
||||||
|
|
||||||
class AdminToast(admin.ModelAdmin):
|
class AdminToast(admin.ModelAdmin):
|
||||||
@@ -308,12 +315,26 @@ class AdminFeedToken(admin.ModelAdmin):
|
|||||||
list_filter = ("user__username",)
|
list_filter = ("user__username",)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ApiToken
|
||||||
|
fields = ("name", "user")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminApiToken(admin.ModelAdmin):
|
||||||
|
form = ApiTokenAdminForm
|
||||||
|
list_display = ("name", "user", "created")
|
||||||
|
search_fields = ["name", "user__username"]
|
||||||
|
list_filter = ("user__username",)
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
linkding_admin_site = LinkdingAdminSite()
|
linkding_admin_site = LinkdingAdminSite()
|
||||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||||
linkding_admin_site.register(Tag, AdminTag)
|
linkding_admin_site.register(Tag, AdminTag)
|
||||||
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||||
linkding_admin_site.register(User, AdminCustomUser)
|
linkding_admin_site.register(User, AdminCustomUser)
|
||||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
linkding_admin_site.register(ApiToken, AdminApiToken)
|
||||||
linkding_admin_site.register(Toast, AdminToast)
|
linkding_admin_site.register(Toast, AdminToast)
|
||||||
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
||||||
|
|
||||||
|
from bookmarks.models import ApiToken
|
||||||
|
|
||||||
|
|
||||||
class LinkdingTokenAuthentication(TokenAuthentication):
|
class LinkdingTokenAuthentication(TokenAuthentication):
|
||||||
"""
|
"""
|
||||||
Extends DRF TokenAuthentication to add support for multiple keywords
|
Extends DRF TokenAuthentication to add support for multiple keywords and
|
||||||
|
multiple tokens per user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model = ApiToken
|
||||||
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
|
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
@@ -29,6 +33,6 @@ class LinkdingTokenAuthentication(TokenAuthentication):
|
|||||||
msg = _(
|
msg = _(
|
||||||
"Invalid token header. Token string should not contain invalid characters."
|
"Invalid token header. Token string should not contain invalid characters."
|
||||||
)
|
)
|
||||||
raise exceptions.AuthenticationFailed(msg)
|
raise exceptions.AuthenticationFailed(msg) from None
|
||||||
|
|
||||||
return self.authenticate_credentials(token)
|
return self.authenticate_credentials(token)
|
||||||
|
|||||||
@@ -4,30 +4,29 @@ import os
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import Http404, StreamingHttpResponse
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import mixins, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import SimpleRouter, DefaultRouter
|
from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.api.serializers import (
|
from bookmarks.api.serializers import (
|
||||||
BookmarkSerializer,
|
|
||||||
BookmarkAssetSerializer,
|
BookmarkAssetSerializer,
|
||||||
|
BookmarkBundleSerializer,
|
||||||
|
BookmarkSerializer,
|
||||||
TagSerializer,
|
TagSerializer,
|
||||||
UserProfileSerializer,
|
UserProfileSerializer,
|
||||||
BookmarkBundleSerializer,
|
|
||||||
)
|
)
|
||||||
from bookmarks.models import (
|
from bookmarks.models import (
|
||||||
Bookmark,
|
Bookmark,
|
||||||
BookmarkAsset,
|
BookmarkAsset,
|
||||||
|
BookmarkBundle,
|
||||||
BookmarkSearch,
|
BookmarkSearch,
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
BookmarkBundle,
|
|
||||||
)
|
)
|
||||||
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
|
from bookmarks.services import assets, auto_tagging, bookmarks, bundles, website_loader
|
||||||
from bookmarks.utils import normalize_url
|
|
||||||
from bookmarks.type_defs import HttpRequest
|
from bookmarks.type_defs import HttpRequest
|
||||||
from bookmarks.views import access
|
from bookmarks.views import access
|
||||||
|
|
||||||
@@ -108,10 +107,7 @@ class BookmarkViewSet(
|
|||||||
def check(self, request: HttpRequest):
|
def check(self, request: HttpRequest):
|
||||||
url = request.GET.get("url")
|
url = request.GET.get("url")
|
||||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||||
normalized_url = normalize_url(url)
|
bookmark = Bookmark.query_existing(request.user, url).first()
|
||||||
bookmark = Bookmark.objects.filter(
|
|
||||||
owner=request.user, url_normalized=normalized_url
|
|
||||||
).first()
|
|
||||||
existing_bookmark_data = (
|
existing_bookmark_data = (
|
||||||
self.get_serializer(bookmark).data if bookmark else None
|
self.get_serializer(bookmark).data if bookmark else None
|
||||||
)
|
)
|
||||||
@@ -155,10 +151,7 @@ class BookmarkViewSet(
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
normalized_url = normalize_url(url)
|
bookmark = Bookmark.query_existing(request.user, url).first()
|
||||||
bookmark = Bookmark.objects.filter(
|
|
||||||
owner=request.user, url_normalized=normalized_url
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not bookmark:
|
if not bookmark:
|
||||||
bookmark = Bookmark(url=url)
|
bookmark = Bookmark(url=url)
|
||||||
@@ -204,7 +197,7 @@ class BookmarkAssetViewSet(
|
|||||||
file_stream = (
|
file_stream = (
|
||||||
gzip.GzipFile(file_path, mode="rb")
|
gzip.GzipFile(file_path, mode="rb")
|
||||||
if asset.gzip
|
if asset.gzip
|
||||||
else open(file_path, "rb")
|
else open(file_path, "rb") # noqa: SIM115
|
||||||
)
|
)
|
||||||
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
||||||
response["Content-Disposition"] = (
|
response["Content-Disposition"] = (
|
||||||
@@ -212,7 +205,7 @@ class BookmarkAssetViewSet(
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise Http404("Asset file does not exist")
|
raise Http404("Asset file does not exist") from None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
|
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from django.db.models import Max, prefetch_related_objects
|
from django.db.models import prefetch_related_objects
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import ListSerializer
|
from rest_framework.serializers import ListSerializer
|
||||||
@@ -6,10 +6,10 @@ from rest_framework.serializers import ListSerializer
|
|||||||
from bookmarks.models import (
|
from bookmarks.models import (
|
||||||
Bookmark,
|
Bookmark,
|
||||||
BookmarkAsset,
|
BookmarkAsset,
|
||||||
Tag,
|
|
||||||
build_tag_string,
|
|
||||||
UserProfile,
|
|
||||||
BookmarkBundle,
|
BookmarkBundle,
|
||||||
|
Tag,
|
||||||
|
UserProfile,
|
||||||
|
build_tag_string,
|
||||||
)
|
)
|
||||||
from bookmarks.services import bookmarks, bundles
|
from bookmarks.services import bookmarks, bundles
|
||||||
from bookmarks.services.tags import get_or_create_tag
|
from bookmarks.services.tags import get_or_create_tag
|
||||||
@@ -44,6 +44,8 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
|
|||||||
"any_tags",
|
"any_tags",
|
||||||
"all_tags",
|
"all_tags",
|
||||||
"excluded_tags",
|
"excluded_tags",
|
||||||
|
"filter_unread",
|
||||||
|
"filter_shared",
|
||||||
"order",
|
"order",
|
||||||
"date_created",
|
"date_created",
|
||||||
"date_modified",
|
"date_modified",
|
||||||
@@ -56,7 +58,7 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
bundle = BookmarkBundle(**validated_data)
|
bundle = BookmarkBundle(**validated_data)
|
||||||
bundle.order = validated_data["order"] if "order" in validated_data else None
|
bundle.order = validated_data.get("order", None)
|
||||||
return bundles.create_bundle(bundle, self.context["user"])
|
return bundles.create_bundle(bundle, self.context["user"])
|
||||||
|
|
||||||
|
|
||||||
@@ -86,8 +88,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"favicon_url",
|
"favicon_url",
|
||||||
"preview_image_url",
|
"preview_image_url",
|
||||||
"tag_names",
|
"tag_names",
|
||||||
"date_added",
|
|
||||||
"date_modified",
|
|
||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
]
|
]
|
||||||
@@ -102,6 +102,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||||
website_title = EmtpyField()
|
website_title = EmtpyField()
|
||||||
website_description = EmtpyField()
|
website_description = EmtpyField()
|
||||||
|
# these are optional
|
||||||
|
date_added = serializers.DateTimeField(required=False)
|
||||||
|
date_modified = serializers.DateTimeField(required=False)
|
||||||
|
|
||||||
def get_favicon_url(self, obj: Bookmark):
|
def get_favicon_url(self, obj: Bookmark):
|
||||||
if not obj.favicon_file:
|
if not obj.favicon_file:
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ class BookmarksConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# Register signal handlers
|
# Register signal handlers
|
||||||
import bookmarks.signals
|
# noinspection PyUnusedImports
|
||||||
|
import bookmarks.signals # noqa: F401
|
||||||
|
|||||||
@@ -50,10 +50,7 @@ class BaseBookmarksFeed(Feed):
|
|||||||
|
|
||||||
def items(self, context: FeedContext):
|
def items(self, context: FeedContext):
|
||||||
limit = context.request.GET.get("limit", 100)
|
limit = context.request.GET.get("limit", 100)
|
||||||
if limit:
|
data = context.query_set[: int(limit)] if limit else list(context.query_set)
|
||||||
data = context.query_set[: int(limit)]
|
|
||||||
else:
|
|
||||||
data = list(context.query_set)
|
|
||||||
prefetch_related_objects(data, "tags")
|
prefetch_related_objects(data, "tags")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,45 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms.utils import ErrorList
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import (
|
from bookmarks.models import (
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
BookmarkBundle,
|
||||||
|
BookmarkSearch,
|
||||||
|
GlobalSettings,
|
||||||
Tag,
|
Tag,
|
||||||
|
UserProfile,
|
||||||
build_tag_string,
|
build_tag_string,
|
||||||
parse_tag_string,
|
parse_tag_string,
|
||||||
sanitize_tag_name,
|
sanitize_tag_name,
|
||||||
)
|
)
|
||||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||||
from bookmarks.type_defs import HttpRequest
|
from bookmarks.type_defs import HttpRequest
|
||||||
from bookmarks.utils import normalize_url
|
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
|
from bookmarks.widgets import (
|
||||||
|
FormCheckbox,
|
||||||
class CustomErrorList(ErrorList):
|
FormErrorList,
|
||||||
template_name = "shared/error_list.html"
|
FormInput,
|
||||||
|
FormNumberInput,
|
||||||
|
FormSelect,
|
||||||
|
FormTextarea,
|
||||||
|
TagAutocomplete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkForm(forms.ModelForm):
|
class BookmarkForm(forms.ModelForm):
|
||||||
# Use URLField for URL
|
# Use URLField for URL
|
||||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
url = forms.CharField(validators=[BookmarkURLValidator()], widget=FormInput)
|
||||||
tag_string = forms.CharField(required=False)
|
tag_string = forms.CharField(required=False, widget=TagAutocomplete)
|
||||||
# Do not require title and description as they may be empty
|
# Do not require title and description as they may be empty
|
||||||
title = forms.CharField(max_length=512, required=False)
|
title = forms.CharField(max_length=512, required=False, widget=FormInput)
|
||||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
description = forms.CharField(required=False, widget=FormTextarea)
|
||||||
unread = forms.BooleanField(required=False)
|
notes = forms.CharField(required=False, widget=FormTextarea)
|
||||||
shared = forms.BooleanField(required=False)
|
unread = forms.BooleanField(required=False, widget=FormCheckbox)
|
||||||
|
shared = forms.BooleanField(required=False, widget=FormCheckbox)
|
||||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||||
auto_close = forms.CharField(required=False)
|
auto_close = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
@@ -63,7 +73,7 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||||
data = request.POST if request.method == "POST" else None
|
data = request.POST if request.method == "POST" else None
|
||||||
super().__init__(
|
super().__init__(
|
||||||
data, instance=instance, initial=initial, error_class=CustomErrorList
|
data, instance=instance, initial=initial, error_class=FormErrorList
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -94,11 +104,8 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
# raise a validation error in that case.
|
# raise a validation error in that case.
|
||||||
url = self.cleaned_data["url"]
|
url = self.cleaned_data["url"]
|
||||||
if self.instance.pk:
|
if self.instance.pk:
|
||||||
normalized_url = normalize_url(url)
|
|
||||||
is_duplicate = (
|
is_duplicate = (
|
||||||
Bookmark.objects.filter(
|
Bookmark.query_existing(self.instance.owner, url)
|
||||||
owner=self.instance.owner, url_normalized=normalized_url
|
|
||||||
)
|
|
||||||
.exclude(pk=self.instance.pk)
|
.exclude(pk=self.instance.pk)
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
@@ -115,12 +122,14 @@ def convert_tag_string(tag_string: str):
|
|||||||
|
|
||||||
|
|
||||||
class TagForm(forms.ModelForm):
|
class TagForm(forms.ModelForm):
|
||||||
|
name = forms.CharField(widget=FormInput)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ["name"]
|
fields = ["name"]
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs, error_class=CustomErrorList)
|
super().__init__(*args, **kwargs, error_class=FormErrorList)
|
||||||
self.user = user
|
self.user = user
|
||||||
|
|
||||||
def clean_name(self):
|
def clean_name(self):
|
||||||
@@ -150,11 +159,11 @@ class TagForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class TagMergeForm(forms.Form):
|
class TagMergeForm(forms.Form):
|
||||||
target_tag = forms.CharField()
|
target_tag = forms.CharField(widget=TagAutocomplete)
|
||||||
merge_tags = forms.CharField()
|
merge_tags = forms.CharField(widget=TagAutocomplete)
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs, error_class=CustomErrorList)
|
super().__init__(*args, **kwargs, error_class=FormErrorList)
|
||||||
self.user = user
|
self.user = user
|
||||||
|
|
||||||
def clean_target_tag(self):
|
def clean_target_tag(self):
|
||||||
@@ -171,7 +180,9 @@ class TagMergeForm(forms.Form):
|
|||||||
try:
|
try:
|
||||||
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
|
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.')
|
raise forms.ValidationError(
|
||||||
|
f'Tag "{target_tag_name}" does not exist.'
|
||||||
|
) from None
|
||||||
|
|
||||||
return target_tag
|
return target_tag
|
||||||
|
|
||||||
@@ -188,7 +199,9 @@ class TagMergeForm(forms.Form):
|
|||||||
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
|
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
|
||||||
merge_tags.append(tag)
|
merge_tags.append(tag)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
raise forms.ValidationError(f'Tag "{tag_name}" does not exist.')
|
raise forms.ValidationError(
|
||||||
|
f'Tag "{tag_name}" does not exist.'
|
||||||
|
) from None
|
||||||
|
|
||||||
target_tag = self.cleaned_data.get("target_tag")
|
target_tag = self.cleaned_data.get("target_tag")
|
||||||
if target_tag and target_tag in merge_tags:
|
if target_tag and target_tag in merge_tags:
|
||||||
@@ -197,3 +210,174 @@ class TagMergeForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return merge_tags
|
return merge_tags
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBundleForm(forms.ModelForm):
|
||||||
|
name = forms.CharField(max_length=256, widget=FormInput)
|
||||||
|
search = forms.CharField(max_length=256, required=False, widget=FormInput)
|
||||||
|
any_tags = forms.CharField(required=False, widget=TagAutocomplete)
|
||||||
|
all_tags = forms.CharField(required=False, widget=TagAutocomplete)
|
||||||
|
excluded_tags = forms.CharField(required=False, widget=TagAutocomplete)
|
||||||
|
filter_unread = forms.ChoiceField(
|
||||||
|
choices=BookmarkBundle.FILTER_UNREAD_CHOICES,
|
||||||
|
required=False,
|
||||||
|
widget=FormSelect,
|
||||||
|
)
|
||||||
|
filter_shared = forms.ChoiceField(
|
||||||
|
choices=BookmarkBundle.FILTER_SHARED_CHOICES,
|
||||||
|
required=False,
|
||||||
|
widget=FormSelect,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BookmarkBundle
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"search",
|
||||||
|
"any_tags",
|
||||||
|
"all_tags",
|
||||||
|
"excluded_tags",
|
||||||
|
"filter_unread",
|
||||||
|
"filter_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs, error_class=FormErrorList)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkSearchForm(forms.Form):
|
||||||
|
SORT_CHOICES = [
|
||||||
|
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
|
||||||
|
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
|
||||||
|
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
|
||||||
|
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
|
||||||
|
]
|
||||||
|
FILTER_SHARED_CHOICES = [
|
||||||
|
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
|
||||||
|
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
|
||||||
|
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
|
||||||
|
]
|
||||||
|
FILTER_UNREAD_CHOICES = [
|
||||||
|
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
|
||||||
|
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
|
||||||
|
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
|
||||||
|
]
|
||||||
|
|
||||||
|
q = forms.CharField()
|
||||||
|
user = forms.ChoiceField(required=False, widget=FormSelect)
|
||||||
|
bundle = forms.CharField(required=False)
|
||||||
|
sort = forms.ChoiceField(choices=SORT_CHOICES, widget=FormSelect)
|
||||||
|
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||||
|
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||||
|
modified_since = forms.CharField(required=False)
|
||||||
|
added_since = forms.CharField(required=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
search: BookmarkSearch,
|
||||||
|
editable_fields: list[str] = None,
|
||||||
|
users: list[User] = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
editable_fields = editable_fields or []
|
||||||
|
self.editable_fields = editable_fields
|
||||||
|
|
||||||
|
# set choices for user field if users are provided
|
||||||
|
if users:
|
||||||
|
user_choices = [(user.username, user.username) for user in users]
|
||||||
|
user_choices.insert(0, ("", "Everyone"))
|
||||||
|
self.fields["user"].choices = user_choices
|
||||||
|
|
||||||
|
for param in search.params:
|
||||||
|
# set initial values for modified params
|
||||||
|
value = search.__dict__.get(param)
|
||||||
|
if isinstance(value, models.Model):
|
||||||
|
self.fields[param].initial = value.id
|
||||||
|
else:
|
||||||
|
self.fields[param].initial = value
|
||||||
|
|
||||||
|
# Mark non-editable modified fields as hidden. That way, templates
|
||||||
|
# rendering a form can just loop over hidden_fields to ensure that
|
||||||
|
# all necessary search options are kept when submitting the form.
|
||||||
|
if search.is_modified(param) and param not in editable_fields:
|
||||||
|
self.fields[param].widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = [
|
||||||
|
"theme",
|
||||||
|
"bookmark_date_display",
|
||||||
|
"bookmark_description_display",
|
||||||
|
"bookmark_description_max_lines",
|
||||||
|
"bookmark_link_target",
|
||||||
|
"web_archive_integration",
|
||||||
|
"tag_search",
|
||||||
|
"tag_grouping",
|
||||||
|
"enable_sharing",
|
||||||
|
"enable_public_sharing",
|
||||||
|
"enable_favicons",
|
||||||
|
"enable_preview_images",
|
||||||
|
"enable_automatic_html_snapshots",
|
||||||
|
"display_url",
|
||||||
|
"display_view_bookmark_action",
|
||||||
|
"display_edit_bookmark_action",
|
||||||
|
"display_archive_bookmark_action",
|
||||||
|
"display_remove_bookmark_action",
|
||||||
|
"permanent_notes",
|
||||||
|
"default_mark_unread",
|
||||||
|
"default_mark_shared",
|
||||||
|
"custom_css",
|
||||||
|
"auto_tagging_rules",
|
||||||
|
"items_per_page",
|
||||||
|
"sticky_pagination",
|
||||||
|
"collapse_side_panel",
|
||||||
|
"hide_bundles",
|
||||||
|
"legacy_search",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"theme": FormSelect,
|
||||||
|
"bookmark_date_display": FormSelect,
|
||||||
|
"bookmark_description_display": FormSelect,
|
||||||
|
"bookmark_description_max_lines": FormNumberInput,
|
||||||
|
"bookmark_link_target": FormSelect,
|
||||||
|
"web_archive_integration": FormSelect,
|
||||||
|
"tag_search": FormSelect,
|
||||||
|
"tag_grouping": FormSelect,
|
||||||
|
"auto_tagging_rules": FormTextarea,
|
||||||
|
"custom_css": FormTextarea,
|
||||||
|
"items_per_page": FormNumberInput,
|
||||||
|
"display_url": FormCheckbox,
|
||||||
|
"permanent_notes": FormCheckbox,
|
||||||
|
"display_view_bookmark_action": FormCheckbox,
|
||||||
|
"display_edit_bookmark_action": FormCheckbox,
|
||||||
|
"display_archive_bookmark_action": FormCheckbox,
|
||||||
|
"display_remove_bookmark_action": FormCheckbox,
|
||||||
|
"sticky_pagination": FormCheckbox,
|
||||||
|
"collapse_side_panel": FormCheckbox,
|
||||||
|
"hide_bundles": FormCheckbox,
|
||||||
|
"legacy_search": FormCheckbox,
|
||||||
|
"enable_favicons": FormCheckbox,
|
||||||
|
"enable_preview_images": FormCheckbox,
|
||||||
|
"enable_sharing": FormCheckbox,
|
||||||
|
"enable_public_sharing": FormCheckbox,
|
||||||
|
"enable_automatic_html_snapshots": FormCheckbox,
|
||||||
|
"default_mark_unread": FormCheckbox,
|
||||||
|
"default_mark_shared": FormCheckbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettingsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = GlobalSettings
|
||||||
|
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
|
||||||
|
widgets = {
|
||||||
|
"landing_page": FormSelect,
|
||||||
|
"guest_profile_user": FormSelect,
|
||||||
|
"enable_link_prefetch": FormCheckbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["guest_profile_user"].empty_label = "Standard profile"
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class BookmarkItem extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
// Toggle notes
|
|
||||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
|
||||||
this.notesToggle = element.querySelector(".toggle-notes");
|
|
||||||
if (this.notesToggle) {
|
|
||||||
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tooltip to title if it is truncated
|
|
||||||
const titleAnchor = element.querySelector(".title > a");
|
|
||||||
const titleSpan = titleAnchor.querySelector("span");
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
|
||||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.notesToggle) {
|
|
||||||
this.notesToggle.removeEventListener("click", this.onToggleNotes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onToggleNotes(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.element.classList.toggle("show-notes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-bookmark-item", BookmarkItem);
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class BulkEdit extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.active = element.classList.contains("active");
|
|
||||||
|
|
||||||
this.init = this.init.bind(this);
|
|
||||||
this.onToggleActive = this.onToggleActive.bind(this);
|
|
||||||
this.onToggleAll = this.onToggleAll.bind(this);
|
|
||||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
|
||||||
this.onActionSelected = this.onActionSelected.bind(this);
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
// Reset when bookmarks are updated
|
|
||||||
document.addEventListener("bookmark-list-updated", this.init);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.removeListeners();
|
|
||||||
document.removeEventListener("bookmark-list-updated", this.init);
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Update elements
|
|
||||||
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
|
||||||
this.actionSelect = this.element.querySelector(
|
|
||||||
"select[name='bulk_action']",
|
|
||||||
);
|
|
||||||
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
|
||||||
this.selectAcross = this.element.querySelector("label.select-across");
|
|
||||||
this.allCheckbox = this.element.querySelector(
|
|
||||||
".bulk-edit-checkbox.all input",
|
|
||||||
);
|
|
||||||
this.bookmarkCheckboxes = Array.from(
|
|
||||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
|
||||||
this.removeListeners();
|
|
||||||
this.addListeners();
|
|
||||||
|
|
||||||
// Reset checkbox states
|
|
||||||
this.reset();
|
|
||||||
|
|
||||||
// Update total number of bookmarks
|
|
||||||
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
|
||||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
|
||||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
|
||||||
totalSpan.textContent = total;
|
|
||||||
}
|
|
||||||
|
|
||||||
addListeners() {
|
|
||||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
|
||||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
|
||||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
|
||||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
|
||||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
removeListeners() {
|
|
||||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
|
||||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
|
||||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
|
||||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
|
||||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onToggleActive() {
|
|
||||||
this.active = !this.active;
|
|
||||||
if (this.active) {
|
|
||||||
this.element.classList.add("active", "activating");
|
|
||||||
setTimeout(() => {
|
|
||||||
this.element.classList.remove("activating");
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
this.element.classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onToggleBookmark() {
|
|
||||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
|
||||||
return checkbox.checked;
|
|
||||||
});
|
|
||||||
this.allCheckbox.checked = allChecked;
|
|
||||||
this.updateSelectAcross(allChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
onToggleAll() {
|
|
||||||
const allChecked = this.allCheckbox.checked;
|
|
||||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
|
||||||
checkbox.checked = allChecked;
|
|
||||||
});
|
|
||||||
this.updateSelectAcross(allChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
onActionSelected() {
|
|
||||||
const action = this.actionSelect.value;
|
|
||||||
|
|
||||||
if (action === "bulk_tag" || action === "bulk_untag") {
|
|
||||||
this.tagAutoComplete.classList.remove("d-none");
|
|
||||||
} else {
|
|
||||||
this.tagAutoComplete.classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectAcross(allChecked) {
|
|
||||||
if (allChecked) {
|
|
||||||
this.selectAcross.classList.remove("d-none");
|
|
||||||
} else {
|
|
||||||
this.selectAcross.classList.add("d-none");
|
|
||||||
this.selectAcross.querySelector("input").checked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.allCheckbox.checked = false;
|
|
||||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
|
||||||
checkbox.checked = false;
|
|
||||||
});
|
|
||||||
this.updateSelectAcross(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-bulk-edit", BulkEdit);
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class ClearButtonBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.field = document.getElementById(element.dataset.for);
|
|
||||||
if (!this.field) {
|
|
||||||
console.error(`Field with ID ${element.dataset.for} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.update = this.update.bind(this);
|
|
||||||
this.clear = this.clear.bind(this);
|
|
||||||
|
|
||||||
this.element.addEventListener("click", this.clear);
|
|
||||||
this.field.addEventListener("input", this.update);
|
|
||||||
this.field.addEventListener("value-changed", this.update);
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (!this.field) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.element.removeEventListener("click", this.clear);
|
|
||||||
this.field.removeEventListener("input", this.update);
|
|
||||||
this.field.removeEventListener("value-changed", this.update);
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
this.element.style.display = this.field.value ? "inline-flex" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.field.value = "";
|
|
||||||
this.field.focus();
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-clear-button", ClearButtonBehavior);
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
|
|
||||||
|
|
||||||
let confirmId = 0;
|
|
||||||
|
|
||||||
function nextConfirmId() {
|
|
||||||
return `confirm-${confirmId++}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConfirmButtonBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.onClick = this.onClick.bind(this);
|
|
||||||
this.element.addEventListener("click", this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.opened) {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
this.element.removeEventListener("click", this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (this.opened) {
|
|
||||||
this.close();
|
|
||||||
} else {
|
|
||||||
this.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
const dropdown = document.createElement("div");
|
|
||||||
dropdown.className = "dropdown confirm-dropdown active";
|
|
||||||
|
|
||||||
const confirmId = nextConfirmId();
|
|
||||||
const questionId = `${confirmId}-question`;
|
|
||||||
|
|
||||||
const menu = document.createElement("div");
|
|
||||||
menu.className = "menu with-arrow";
|
|
||||||
menu.role = "alertdialog";
|
|
||||||
menu.setAttribute("aria-modal", "true");
|
|
||||||
menu.setAttribute("aria-labelledby", questionId);
|
|
||||||
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
|
|
||||||
|
|
||||||
const question = document.createElement("span");
|
|
||||||
question.id = questionId;
|
|
||||||
question.textContent =
|
|
||||||
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
|
|
||||||
question.style.fontWeight = "bold";
|
|
||||||
|
|
||||||
const cancelButton = document.createElement("button");
|
|
||||||
cancelButton.textContent = "Cancel";
|
|
||||||
cancelButton.type = "button";
|
|
||||||
cancelButton.className = "btn";
|
|
||||||
cancelButton.tabIndex = 0;
|
|
||||||
cancelButton.addEventListener("click", () => this.close());
|
|
||||||
|
|
||||||
const confirmButton = document.createElement("button");
|
|
||||||
confirmButton.textContent = "Confirm";
|
|
||||||
confirmButton.type = "submit";
|
|
||||||
confirmButton.name = this.element.name;
|
|
||||||
confirmButton.value = this.element.value;
|
|
||||||
confirmButton.className = "btn btn-error";
|
|
||||||
confirmButton.addEventListener("click", () => this.confirm());
|
|
||||||
|
|
||||||
const arrow = document.createElement("div");
|
|
||||||
arrow.className = "menu-arrow";
|
|
||||||
|
|
||||||
menu.append(question, cancelButton, confirmButton, arrow);
|
|
||||||
dropdown.append(menu);
|
|
||||||
document.body.append(dropdown);
|
|
||||||
|
|
||||||
this.positionController = new AnchorPositionController(this.element, menu);
|
|
||||||
this.focusTrap = new FocusTrapController(menu);
|
|
||||||
this.dropdown = dropdown;
|
|
||||||
this.opened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMenuKeyDown(event) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm() {
|
|
||||||
this.element.closest("form").requestSubmit(this.element);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (!this.opened) return;
|
|
||||||
this.positionController.destroy();
|
|
||||||
this.focusTrap.destroy();
|
|
||||||
this.dropdown.remove();
|
|
||||||
this.element.focus({ focusVisible: isKeyboardActive() });
|
|
||||||
this.opened = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AnchorPositionController {
|
|
||||||
constructor(anchor, overlay) {
|
|
||||||
this.anchor = anchor;
|
|
||||||
this.overlay = overlay;
|
|
||||||
|
|
||||||
this.handleScroll = this.handleScroll.bind(this);
|
|
||||||
window.addEventListener("scroll", this.handleScroll, { capture: true });
|
|
||||||
|
|
||||||
this.updatePosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleScroll() {
|
|
||||||
if (this.debounce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.debounce = true;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.updatePosition();
|
|
||||||
this.debounce = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePosition() {
|
|
||||||
const anchorRect = this.anchor.getBoundingClientRect();
|
|
||||||
const overlayRect = this.overlay.getBoundingClientRect();
|
|
||||||
const bufferX = 10;
|
|
||||||
const bufferY = 30;
|
|
||||||
|
|
||||||
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
|
|
||||||
const initialLeft = left;
|
|
||||||
const overflowLeft = left < bufferX;
|
|
||||||
const overflowRight =
|
|
||||||
left + overlayRect.width > window.innerWidth - bufferX;
|
|
||||||
|
|
||||||
if (overflowLeft) {
|
|
||||||
left = bufferX;
|
|
||||||
} else if (overflowRight) {
|
|
||||||
left = window.innerWidth - overlayRect.width - bufferX;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = initialLeft - left;
|
|
||||||
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
|
|
||||||
|
|
||||||
let top = anchorRect.bottom;
|
|
||||||
const overflowBottom =
|
|
||||||
top + overlayRect.height > window.innerHeight - bufferY;
|
|
||||||
|
|
||||||
if (overflowBottom) {
|
|
||||||
top = anchorRect.top - overlayRect.height;
|
|
||||||
this.overlay.classList.remove("top-aligned");
|
|
||||||
this.overlay.classList.add("bottom-aligned");
|
|
||||||
} else {
|
|
||||||
this.overlay.classList.remove("bottom-aligned");
|
|
||||||
this.overlay.classList.add("top-aligned");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.overlay.style.left = `${left}px`;
|
|
||||||
this.overlay.style.top = `${top}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
window.removeEventListener("scroll", this.handleScroll, { capture: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { registerBehavior } from "./index";
|
|
||||||
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
|
|
||||||
import { ModalBehavior } from "./modal";
|
|
||||||
|
|
||||||
class DetailsModalBehavior extends ModalBehavior {
|
|
||||||
doClose() {
|
|
||||||
super.doClose();
|
|
||||||
|
|
||||||
// Navigate to close URL
|
|
||||||
const closeUrl = this.element.dataset.closeUrl;
|
|
||||||
Turbo.visit(closeUrl, {
|
|
||||||
action: "replace",
|
|
||||||
frame: "details-modal",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try restore focus to view details to view details link of respective bookmark
|
|
||||||
const bookmarkId = this.element.dataset.bookmarkId;
|
|
||||||
setAfterPageLoadFocusTarget(
|
|
||||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-details-modal", DetailsModalBehavior);
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
import { ModalBehavior } from "./modal";
|
|
||||||
import { isKeyboardActive } from "./focus-utils";
|
|
||||||
|
|
||||||
class FilterDrawerTriggerBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.onClick = this.onClick.bind(this);
|
|
||||||
|
|
||||||
element.addEventListener("click", this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.element.removeEventListener("click", this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick() {
|
|
||||||
const modal = document.createElement("div");
|
|
||||||
modal.classList.add("modal", "drawer", "filter-drawer");
|
|
||||||
modal.setAttribute("ld-filter-drawer", "");
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-container" role="dialog" aria-modal="true">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Filters</h2>
|
|
||||||
<button class="btn btn-noborder close" aria-label="Close dialog">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
||||||
<path d="M18 6l-12 12"></path>
|
|
||||||
<path d="M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.querySelector(".modals").appendChild(modal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FilterDrawerBehavior extends ModalBehavior {
|
|
||||||
init() {
|
|
||||||
// Teleport content before creating focus trap, otherwise it will not detect
|
|
||||||
// focusable content elements
|
|
||||||
this.teleport();
|
|
||||||
super.init();
|
|
||||||
// Add active class to start slide-in animation
|
|
||||||
this.element.classList.add("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
super.destroy();
|
|
||||||
// Always close on destroy to restore drawer content to original location
|
|
||||||
// before turbo caches DOM
|
|
||||||
this.doClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
mapHeading(container, from, to) {
|
|
||||||
const headings = container.querySelectorAll(from);
|
|
||||||
headings.forEach((heading) => {
|
|
||||||
const newHeading = document.createElement(to);
|
|
||||||
newHeading.textContent = heading.textContent;
|
|
||||||
heading.replaceWith(newHeading);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
teleport() {
|
|
||||||
const content = this.element.querySelector(".content");
|
|
||||||
const sidePanel = document.querySelector(".side-panel");
|
|
||||||
content.append(...sidePanel.children);
|
|
||||||
this.mapHeading(content, "h2", "h3");
|
|
||||||
}
|
|
||||||
|
|
||||||
teleportBack() {
|
|
||||||
const sidePanel = document.querySelector(".side-panel");
|
|
||||||
const content = this.element.querySelector(".content");
|
|
||||||
sidePanel.append(...content.children);
|
|
||||||
this.mapHeading(sidePanel, "h3", "h2");
|
|
||||||
}
|
|
||||||
|
|
||||||
doClose() {
|
|
||||||
super.doClose();
|
|
||||||
this.teleportBack();
|
|
||||||
|
|
||||||
// Try restore focus to drawer trigger
|
|
||||||
const restoreFocusElement =
|
|
||||||
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
|
||||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
|
||||||
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class FormSubmit extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
|
||||||
this.element.addEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeyDown(event) {
|
|
||||||
// Check for Ctrl/Cmd + Enter combination
|
|
||||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.element.requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AutoSubmitBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.submit = this.submit.bind(this);
|
|
||||||
element.addEventListener("change", this.submit);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.element.removeEventListener("change", this.submit);
|
|
||||||
}
|
|
||||||
|
|
||||||
submit() {
|
|
||||||
this.element.closest("form").requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resets form controls to their initial values before Turbo caches the DOM.
|
|
||||||
// Useful for filter forms where navigating back would otherwise still show
|
|
||||||
// values from after the form submission, which means the filters would be out
|
|
||||||
// of sync with the URL.
|
|
||||||
class FormResetBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.controls = this.element.querySelectorAll("input, select");
|
|
||||||
this.controls.forEach((control) => {
|
|
||||||
if (control.type === "checkbox" || control.type === "radio") {
|
|
||||||
control.__initialValue = control.checked;
|
|
||||||
} else {
|
|
||||||
control.__initialValue = control.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.controls.forEach((control) => {
|
|
||||||
if (control.type === "checkbox" || control.type === "radio") {
|
|
||||||
control.checked = control.__initialValue;
|
|
||||||
} else {
|
|
||||||
control.value = control.__initialValue;
|
|
||||||
}
|
|
||||||
delete control.__initialValue;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UploadButton extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
this.fileInput = element.nextElementSibling;
|
|
||||||
|
|
||||||
this.onClick = this.onClick.bind(this);
|
|
||||||
this.onChange = this.onChange.bind(this);
|
|
||||||
|
|
||||||
element.addEventListener("click", this.onClick);
|
|
||||||
this.fileInput.addEventListener("change", this.onChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.element.removeEventListener("click", this.onClick);
|
|
||||||
this.fileInput.removeEventListener("change", this.onChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.fileInput.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange() {
|
|
||||||
// Check if the file input has a file selected
|
|
||||||
if (!this.fileInput.files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const form = this.fileInput.closest("form");
|
|
||||||
form.requestSubmit(this.element);
|
|
||||||
// remove selected file so it doesn't get submitted again
|
|
||||||
this.fileInput.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-form-submit", FormSubmit);
|
|
||||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
|
||||||
registerBehavior("ld-form-reset", FormResetBehavior);
|
|
||||||
registerBehavior("ld-upload-button", UploadButton);
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class GlobalShortcuts extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
|
||||||
document.addEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeyDown(event) {
|
|
||||||
// Skip if event occurred within an input element
|
|
||||||
const targetNodeName = event.target.nodeName;
|
|
||||||
const isInputTarget =
|
|
||||||
targetNodeName === "INPUT" ||
|
|
||||||
targetNodeName === "SELECT" ||
|
|
||||||
targetNodeName === "TEXTAREA";
|
|
||||||
|
|
||||||
if (isInputTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle shortcuts for navigating bookmarks with arrow keys
|
|
||||||
const isArrowUp = event.key === "ArrowUp";
|
|
||||||
const isArrowDown = event.key === "ArrowDown";
|
|
||||||
if (isArrowUp || isArrowDown) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Detect current bookmark list item
|
|
||||||
const path = event.composedPath();
|
|
||||||
const currentItem = path.find(
|
|
||||||
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find next item
|
|
||||||
let nextItem;
|
|
||||||
if (currentItem) {
|
|
||||||
nextItem = isArrowUp
|
|
||||||
? currentItem.previousElementSibling
|
|
||||||
: currentItem.nextElementSibling;
|
|
||||||
} else {
|
|
||||||
// Select first item
|
|
||||||
nextItem = document.querySelector("[ld-bookmark-item]");
|
|
||||||
}
|
|
||||||
// Focus first link
|
|
||||||
if (nextItem) {
|
|
||||||
nextItem.querySelector("a").focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle shortcut for toggling all notes
|
|
||||||
if (event.key === "e") {
|
|
||||||
const list = document.querySelector(".bookmark-list");
|
|
||||||
if (list) {
|
|
||||||
list.classList.toggle("show-notes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle shortcut for focusing search input
|
|
||||||
if (event.key === "s") {
|
|
||||||
const searchInput = document.querySelector('input[type="search"]');
|
|
||||||
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle shortcut for adding new bookmark
|
|
||||||
if (event.key === "n") {
|
|
||||||
window.location.assign("/bookmarks/new");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-global-shortcuts", GlobalShortcuts);
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
const behaviorRegistry = {};
|
|
||||||
const debug = false;
|
|
||||||
|
|
||||||
const mutationObserver = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
mutation.removedNodes.forEach((node) => {
|
|
||||||
if (node instanceof HTMLElement && !node.isConnected) {
|
|
||||||
destroyBehaviors(node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node instanceof HTMLElement && node.isConnected) {
|
|
||||||
applyBehaviors(node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update behaviors on Turbo events
|
|
||||||
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
|
|
||||||
// - turbo:render: after page navigation, including back/forward, and failed form submissions
|
|
||||||
// - turbo:before-cache: before page navigation, reset DOM before caching
|
|
||||||
document.addEventListener(
|
|
||||||
"turbo:load",
|
|
||||||
() => {
|
|
||||||
mutationObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
applyBehaviors(document.body);
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener("turbo:render", () => {
|
|
||||||
mutationObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
applyBehaviors(document.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("turbo:before-cache", () => {
|
|
||||||
destroyBehaviors(document.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
export class Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
this.element = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerBehavior(name, behavior) {
|
|
||||||
behaviorRegistry[name] = behavior;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyBehaviors(container, behaviorNames = null) {
|
|
||||||
if (!behaviorNames) {
|
|
||||||
behaviorNames = Object.keys(behaviorRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
behaviorNames.forEach((behaviorName) => {
|
|
||||||
const behavior = behaviorRegistry[behaviorName];
|
|
||||||
const elements = Array.from(
|
|
||||||
container.querySelectorAll(`[${behaviorName}]`),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Include the container element if it has the behavior
|
|
||||||
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
|
|
||||||
elements.push(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.forEach((element) => {
|
|
||||||
element.__behaviors = element.__behaviors || [];
|
|
||||||
const hasBehavior = element.__behaviors.some(
|
|
||||||
(b) => b instanceof behavior,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasBehavior) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const behaviorInstance = new behavior(element);
|
|
||||||
element.__behaviors.push(behaviorInstance);
|
|
||||||
if (debug) {
|
|
||||||
console.log(
|
|
||||||
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function destroyBehaviors(element) {
|
|
||||||
const behaviorNames = Object.keys(behaviorRegistry);
|
|
||||||
|
|
||||||
behaviorNames.forEach((behaviorName) => {
|
|
||||||
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
|
|
||||||
elements.push(element);
|
|
||||||
|
|
||||||
elements.forEach((element) => {
|
|
||||||
if (!element.__behaviors) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.__behaviors.forEach((behavior) => {
|
|
||||||
behavior.destroy();
|
|
||||||
if (debug) {
|
|
||||||
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
delete element.__behaviors;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
import "../components/SearchAutocomplete.js";
|
|
||||||
|
|
||||||
class SearchAutocomplete extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
const input = element.querySelector("input");
|
|
||||||
if (!input) {
|
|
||||||
console.warn("SearchAutocomplete: input element not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autocomplete = document.createElement("ld-search-autocomplete");
|
|
||||||
autocomplete.name = "q";
|
|
||||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
|
||||||
autocomplete.value = input.value;
|
|
||||||
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
|
|
||||||
autocomplete.mode = input.dataset.mode || "";
|
|
||||||
autocomplete.search = {
|
|
||||||
user: input.dataset.user,
|
|
||||||
shared: input.dataset.shared,
|
|
||||||
unread: input.dataset.unread,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.input = input;
|
|
||||||
this.autocomplete = autocomplete;
|
|
||||||
input.replaceWith(this.autocomplete);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.autocomplete.replaceWith(this.input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-search-autocomplete", SearchAutocomplete);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
import "../components/TagAutocomplete.js";
|
|
||||||
|
|
||||||
class TagAutocomplete extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
const input = element.querySelector("input");
|
|
||||||
if (!input) {
|
|
||||||
console.warn("TagAutocomplete: input element not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autocomplete = document.createElement("ld-tag-autocomplete");
|
|
||||||
autocomplete.id = input.id;
|
|
||||||
autocomplete.name = input.name;
|
|
||||||
autocomplete.value = input.value;
|
|
||||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
|
||||||
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
|
|
||||||
autocomplete.variant = input.getAttribute("variant") || "default";
|
|
||||||
|
|
||||||
this.input = input;
|
|
||||||
this.autocomplete = autocomplete;
|
|
||||||
input.replaceWith(this.autocomplete);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.autocomplete.replaceWith(this.input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-tag-autocomplete", TagAutocomplete);
|
|
||||||
153
bookmarks/frontend/components/bookmark-page.js
Normal file
153
bookmarks/frontend/components/bookmark-page.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { HeadlessElement } from "../utils/element.js";
|
||||||
|
|
||||||
|
class BookmarkPage extends HeadlessElement {
|
||||||
|
init() {
|
||||||
|
this.update = this.update.bind(this);
|
||||||
|
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||||
|
this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this);
|
||||||
|
this.onBulkActionChange = this.onBulkActionChange.bind(this);
|
||||||
|
this.onToggleAll = this.onToggleAll.bind(this);
|
||||||
|
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||||
|
|
||||||
|
this.oldItems = [];
|
||||||
|
this.update();
|
||||||
|
document.addEventListener("bookmark-list-updated", this.update);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener("bookmark-list-updated", this.update);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const items = this.querySelectorAll("ul.bookmark-list > li");
|
||||||
|
this.updateTooltips(items);
|
||||||
|
this.updateNotesToggles(items, this.oldItems);
|
||||||
|
this.updateBulkEdit(items, this.oldItems);
|
||||||
|
this.oldItems = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTooltips(items) {
|
||||||
|
// Add tooltip to title if it is truncated
|
||||||
|
items.forEach((item) => {
|
||||||
|
const titleAnchor = item.querySelector(".title > a");
|
||||||
|
const titleSpan = titleAnchor.querySelector("span");
|
||||||
|
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||||
|
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||||
|
} else {
|
||||||
|
delete titleAnchor.dataset.tooltip;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNotesToggles(items, oldItems) {
|
||||||
|
oldItems.forEach((oldItem) => {
|
||||||
|
const oldToggle = oldItem.querySelector(".toggle-notes");
|
||||||
|
if (oldToggle) {
|
||||||
|
oldToggle.removeEventListener("click", this.onToggleNotes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const notesToggle = item.querySelector(".toggle-notes");
|
||||||
|
if (notesToggle) {
|
||||||
|
notesToggle.addEventListener("click", this.onToggleNotes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleNotes(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.target.closest("li").classList.toggle("show-notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBulkEdit() {
|
||||||
|
if (this.hasAttribute("no-bulk-edit")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing listeners
|
||||||
|
this.activeToggle?.removeEventListener("click", this.onToggleBulkEdit);
|
||||||
|
this.actionSelect?.removeEventListener("change", this.onBulkActionChange);
|
||||||
|
this.allCheckbox?.removeEventListener("change", this.onToggleAll);
|
||||||
|
this.bookmarkCheckboxes?.forEach((checkbox) => {
|
||||||
|
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-query elements
|
||||||
|
this.activeToggle = this.querySelector(".bulk-edit-active-toggle");
|
||||||
|
this.actionSelect = this.querySelector("select[name='bulk_action']");
|
||||||
|
this.allCheckbox = this.querySelector(".bulk-edit-checkbox.all input");
|
||||||
|
this.bookmarkCheckboxes = Array.from(
|
||||||
|
this.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||||
|
);
|
||||||
|
this.selectAcross = this.querySelector("label.select-across");
|
||||||
|
this.executeButton = this.querySelector("button[name='bulk_execute']");
|
||||||
|
|
||||||
|
// Add listeners
|
||||||
|
this.activeToggle.addEventListener("click", this.onToggleBulkEdit);
|
||||||
|
this.actionSelect.addEventListener("change", this.onBulkActionChange);
|
||||||
|
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset checkbox states
|
||||||
|
this.allCheckbox.checked = false;
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = false;
|
||||||
|
});
|
||||||
|
this.updateSelectAcross(false);
|
||||||
|
this.updateExecuteButton();
|
||||||
|
|
||||||
|
// Update total number of bookmarks
|
||||||
|
const totalHolder = this.querySelector("[data-bookmarks-total]");
|
||||||
|
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||||
|
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||||
|
totalSpan.textContent = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleBulkEdit() {
|
||||||
|
this.classList.toggle("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
onBulkActionChange() {
|
||||||
|
this.dataset.bulkAction = this.actionSelect.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleAll() {
|
||||||
|
const allChecked = this.allCheckbox.checked;
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = allChecked;
|
||||||
|
});
|
||||||
|
this.updateSelectAcross(allChecked);
|
||||||
|
this.updateExecuteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleBookmark() {
|
||||||
|
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||||
|
return checkbox.checked;
|
||||||
|
});
|
||||||
|
this.allCheckbox.checked = allChecked;
|
||||||
|
this.updateSelectAcross(allChecked);
|
||||||
|
this.updateExecuteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectAcross(allChecked) {
|
||||||
|
if (allChecked) {
|
||||||
|
this.selectAcross.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.selectAcross.classList.add("d-none");
|
||||||
|
this.selectAcross.querySelector("input").checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExecuteButton() {
|
||||||
|
const anyChecked = this.bookmarkCheckboxes.some((checkbox) => {
|
||||||
|
return checkbox.checked;
|
||||||
|
});
|
||||||
|
this.executeButton.disabled = !anyChecked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-bookmark-page", BookmarkPage);
|
||||||
30
bookmarks/frontend/components/clear-button.js
Normal file
30
bookmarks/frontend/components/clear-button.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { HeadlessElement } from "../utils/element";
|
||||||
|
|
||||||
|
class ClearButton extends HeadlessElement {
|
||||||
|
init() {
|
||||||
|
this.field = document.getElementById(this.dataset.for);
|
||||||
|
if (!this.field) {
|
||||||
|
console.error(`Field with ID ${this.dataset.for} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.update = this.update.bind(this);
|
||||||
|
this.clear = this.clear.bind(this);
|
||||||
|
|
||||||
|
this.addEventListener("click", this.clear);
|
||||||
|
this.field.addEventListener("input", this.update);
|
||||||
|
this.field.addEventListener("value-changed", this.update);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.style.display = this.field.value ? "inline" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.field.value = "";
|
||||||
|
this.field.focus();
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-clear-button", ClearButton);
|
||||||
103
bookmarks/frontend/components/confirm-dropdown.js
Normal file
103
bookmarks/frontend/components/confirm-dropdown.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { html, LitElement } from "lit";
|
||||||
|
import { FocusTrapController, isKeyboardActive } from "../utils/focus.js";
|
||||||
|
import { PositionController } from "../utils/position-controller.js";
|
||||||
|
|
||||||
|
let confirmId = 0;
|
||||||
|
|
||||||
|
function nextConfirmId() {
|
||||||
|
return `confirm-${confirmId++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAll() {
|
||||||
|
document
|
||||||
|
.querySelectorAll("ld-confirm-dropdown")
|
||||||
|
.forEach((dropdown) => dropdown.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a confirm dropdown whenever a button with the data-confirm attribute is clicked
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
// Check if the clicked element is a button with data-confirm
|
||||||
|
const button = event.target.closest("button[data-confirm]");
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
// Remove any existing confirm dropdowns
|
||||||
|
removeAll();
|
||||||
|
|
||||||
|
// Show confirmation dropdown
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const dropdown = document.createElement("ld-confirm-dropdown");
|
||||||
|
dropdown.button = button;
|
||||||
|
document.body.appendChild(dropdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove all confirm dropdowns when:
|
||||||
|
// - Turbo caches the page
|
||||||
|
// - The escape key is pressed
|
||||||
|
document.addEventListener("turbo:before-cache", removeAll);
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
removeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
class ConfirmDropdown extends LitElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.confirmId = nextConfirmId();
|
||||||
|
}
|
||||||
|
|
||||||
|
createRenderRoot() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated(props) {
|
||||||
|
super.firstUpdated(props);
|
||||||
|
this.classList.add("dropdown", "confirm-dropdown", "active");
|
||||||
|
|
||||||
|
const menu = this.querySelector(".menu");
|
||||||
|
this.positionController = new PositionController({
|
||||||
|
anchor: this.button,
|
||||||
|
overlay: menu,
|
||||||
|
arrow: this.querySelector(".menu-arrow"),
|
||||||
|
offset: 12,
|
||||||
|
});
|
||||||
|
this.positionController.enable();
|
||||||
|
this.focusTrap = new FocusTrapController(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const questionText = this.button.dataset.confirmQuestion || "Are you sure?";
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="menu with-arrow"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby=${this.confirmId}
|
||||||
|
>
|
||||||
|
<span id=${this.confirmId} style="font-weight: bold;">
|
||||||
|
${questionText}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn" @click=${this.close}>Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-error" @click=${this.confirm}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<div class="menu-arrow"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
this.button.closest("form").requestSubmit(this.button);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.positionController.disable();
|
||||||
|
this.focusTrap.destroy();
|
||||||
|
this.remove();
|
||||||
|
this.button.focus({ focusVisible: isKeyboardActive() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-confirm-dropdown", ConfirmDropdown);
|
||||||
16
bookmarks/frontend/components/details-modal.js
Normal file
16
bookmarks/frontend/components/details-modal.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { setAfterPageLoadFocusTarget } from "../utils/focus.js";
|
||||||
|
import { Modal } from "./modal.js";
|
||||||
|
|
||||||
|
class DetailsModal extends Modal {
|
||||||
|
doClose() {
|
||||||
|
super.doClose();
|
||||||
|
|
||||||
|
// Try restore focus to view details to view details link of respective bookmark
|
||||||
|
const bookmarkId = this.dataset.bookmarkId;
|
||||||
|
setAfterPageLoadFocusTarget(
|
||||||
|
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-details-modal", DetailsModal);
|
||||||
257
bookmarks/frontend/components/dev-tool.js
Normal file
257
bookmarks/frontend/components/dev-tool.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { LitElement, html, css } from "lit";
|
||||||
|
|
||||||
|
class DevTool extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
profile: { type: Object, state: true },
|
||||||
|
formAction: { type: String, attribute: "data-form-action" },
|
||||||
|
csrfToken: { type: String, attribute: "data-csrf-token" },
|
||||||
|
isOpen: { type: Boolean, state: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background: var(--btn-primary-bg-color);
|
||||||
|
color: var(--btn-primary-text-color);
|
||||||
|
border: none;
|
||||||
|
padding: var(--unit-2);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--btn-box-shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
height: auto;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: var(--body-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--unit-2);
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
min-width: 220px;
|
||||||
|
box-shadow: var(--box-shadow-lg);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) .overlay {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 var(--unit-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--unit-1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
label:has(select) {
|
||||||
|
margin-bottom: var(--unit-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
label:has(select) span {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: var(--unit-2) 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
static fields = [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "theme",
|
||||||
|
label: "Theme",
|
||||||
|
options: [
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "bookmark_date_display",
|
||||||
|
label: "Date",
|
||||||
|
options: [
|
||||||
|
{ value: "relative", label: "Relative" },
|
||||||
|
{ value: "absolute", label: "Absolute" },
|
||||||
|
{ value: "hidden", label: "Hidden" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "bookmark_description_display",
|
||||||
|
label: "Description",
|
||||||
|
options: [
|
||||||
|
{ value: "inline", label: "Inline" },
|
||||||
|
{ value: "separate", label: "Separate" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "checkbox", key: "enable_favicons", label: "Favicons" },
|
||||||
|
{ type: "checkbox", key: "enable_preview_images", label: "Preview images" },
|
||||||
|
{ type: "checkbox", key: "display_url", label: "Display URL" },
|
||||||
|
{ type: "checkbox", key: "permanent_notes", label: "Permanent notes" },
|
||||||
|
{ type: "checkbox", key: "collapse_side_panel", label: "Collapse sidebar" },
|
||||||
|
{ type: "checkbox", key: "sticky_pagination", label: "Sticky pagination" },
|
||||||
|
{ type: "checkbox", key: "hide_bundles", label: "Hide bundles" },
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.isOpen = false;
|
||||||
|
this.profile = {};
|
||||||
|
this._onOutsideClick = this._onOutsideClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
const profileData = document.getElementById("json_profile");
|
||||||
|
this.profile = JSON.parse(profileData.textContent || "{}");
|
||||||
|
document.addEventListener("click", this._onOutsideClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener("click", this._onOutsideClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onOutsideClick(e) {
|
||||||
|
if (!this.contains(e.target) && this.isOpen) {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.removeAttribute("open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggle() {
|
||||||
|
this.isOpen = !this.isOpen;
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
} else {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleChange(key, value) {
|
||||||
|
this.profile = { ...this.profile, [key]: value };
|
||||||
|
if (key === "theme") {
|
||||||
|
const themeLinks = document.head.querySelectorAll('link[href*="theme"]');
|
||||||
|
themeLinks.forEach((link) => link.remove());
|
||||||
|
}
|
||||||
|
this._submitForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderField(field) {
|
||||||
|
switch (field.type) {
|
||||||
|
case "checkbox":
|
||||||
|
return html`
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${this.profile[field.key] || false}
|
||||||
|
@change=${(e) => this._handleChange(field.key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
${field.label}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
case "select":
|
||||||
|
return html`
|
||||||
|
<label>
|
||||||
|
<span>${field.label}:</span>
|
||||||
|
<select
|
||||||
|
@change=${(e) => this._handleChange(field.key, e.target.value)}
|
||||||
|
>
|
||||||
|
${field.options.map(
|
||||||
|
(opt) => html`
|
||||||
|
<option
|
||||||
|
value=${opt.value}
|
||||||
|
?selected=${this.profile[field.key] === opt.value}
|
||||||
|
>
|
||||||
|
${opt.label}
|
||||||
|
</option>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
case "divider":
|
||||||
|
return html`<hr />`;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _submitForm() {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("csrfmiddlewaretoken", this.csrfToken);
|
||||||
|
|
||||||
|
// Profile fields
|
||||||
|
for (const [key, value] of Object.entries(this.profile)) {
|
||||||
|
if (typeof value === "boolean" && value) {
|
||||||
|
formData.append(key, "on");
|
||||||
|
} else if (typeof value !== "boolean") {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit button name that settings.update expects
|
||||||
|
formData.append("update_profile", "1");
|
||||||
|
|
||||||
|
await fetch(this.formAction, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set("ts", Date.now().toString());
|
||||||
|
window.history.replaceState({}, "", url);
|
||||||
|
|
||||||
|
Turbo.visit(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<button class="button" @click=${() => this._toggle()}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065"
|
||||||
|
/>
|
||||||
|
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="overlay">
|
||||||
|
<h3>Dev Tools</h3>
|
||||||
|
${DevTool.fields.map((field) => this._renderField(field))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-dev-tool", DevTool);
|
||||||
@@ -1,43 +1,42 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { HeadlessElement } from "../utils/element.js";
|
||||||
|
|
||||||
class DropdownBehavior extends Behavior {
|
class Dropdown extends HeadlessElement {
|
||||||
constructor(element) {
|
constructor() {
|
||||||
super(element);
|
super();
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
this.onClick = this.onClick.bind(this);
|
this.onClick = this.onClick.bind(this);
|
||||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||||
this.onEscape = this.onEscape.bind(this);
|
this.onEscape = this.onEscape.bind(this);
|
||||||
this.onFocusOut = this.onFocusOut.bind(this);
|
this.onFocusOut = this.onFocusOut.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
// Prevent opening the dropdown automatically on focus, so that it only
|
// Prevent opening the dropdown automatically on focus, so that it only
|
||||||
// opens on click then JS is enabled
|
// opens on click when JS is enabled
|
||||||
this.element.style.setProperty("--dropdown-focus-display", "none");
|
this.style.setProperty("--dropdown-focus-display", "none");
|
||||||
this.element.addEventListener("keydown", this.onEscape);
|
this.addEventListener("keydown", this.onEscape);
|
||||||
this.element.addEventListener("focusout", this.onFocusOut);
|
this.addEventListener("focusout", this.onFocusOut);
|
||||||
|
|
||||||
this.toggle = element.querySelector(".dropdown-toggle");
|
this.toggle = this.querySelector(".dropdown-toggle");
|
||||||
this.toggle.setAttribute("aria-expanded", "false");
|
this.toggle.setAttribute("aria-expanded", "false");
|
||||||
this.toggle.addEventListener("click", this.onClick);
|
this.toggle.addEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
disconnectedCallback() {
|
||||||
this.close();
|
this.close();
|
||||||
this.toggle.removeEventListener("click", this.onClick);
|
|
||||||
this.element.removeEventListener("keydown", this.onEscape);
|
|
||||||
this.element.removeEventListener("focusout", this.onFocusOut);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
this.opened = true;
|
this.opened = true;
|
||||||
this.element.classList.add("active");
|
this.classList.add("active");
|
||||||
this.toggle.setAttribute("aria-expanded", "true");
|
this.toggle.setAttribute("aria-expanded", "true");
|
||||||
document.addEventListener("click", this.onOutsideClick);
|
document.addEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
this.element.classList.remove("active");
|
this.classList.remove("active");
|
||||||
this.toggle.setAttribute("aria-expanded", "false");
|
this.toggle?.setAttribute("aria-expanded", "false");
|
||||||
document.removeEventListener("click", this.onOutsideClick);
|
document.removeEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ class DropdownBehavior extends Behavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onOutsideClick(event) {
|
onOutsideClick(event) {
|
||||||
if (!this.element.contains(event.target)) {
|
if (!this.contains(event.target)) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,10 +63,10 @@ class DropdownBehavior extends Behavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFocusOut(event) {
|
onFocusOut(event) {
|
||||||
if (!this.element.contains(event.relatedTarget)) {
|
if (!this.contains(event.relatedTarget)) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
customElements.define("ld-dropdown", Dropdown);
|
||||||
110
bookmarks/frontend/components/filter-drawer.js
Normal file
110
bookmarks/frontend/components/filter-drawer.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { html, render } from "lit";
|
||||||
|
import { Modal } from "./modal.js";
|
||||||
|
import { HeadlessElement } from "../utils/element.js";
|
||||||
|
import { isKeyboardActive } from "../utils/focus.js";
|
||||||
|
|
||||||
|
class FilterDrawerTrigger extends HeadlessElement {
|
||||||
|
init() {
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
|
this.addEventListener("click", this.onClick.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
const modal = document.createElement("ld-filter-drawer");
|
||||||
|
document.body.querySelector(".modals").appendChild(modal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-filter-drawer-trigger", FilterDrawerTrigger);
|
||||||
|
|
||||||
|
class FilterDrawer extends Modal {
|
||||||
|
connectedCallback() {
|
||||||
|
this.classList.add("modal", "drawer");
|
||||||
|
|
||||||
|
// Render modal structure
|
||||||
|
render(
|
||||||
|
html`
|
||||||
|
<div class="modal-overlay" data-close-modal></div>
|
||||||
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<button
|
||||||
|
class="btn btn-noborder close"
|
||||||
|
aria-label="Close dialog"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M18 6l-12 12"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
// Teleport filter content
|
||||||
|
this.teleport();
|
||||||
|
// Force close on turbo cache to restore content
|
||||||
|
this.doClose = this.doClose.bind(this);
|
||||||
|
document.addEventListener("turbo:before-cache", this.doClose);
|
||||||
|
// Force reflow to make transform transition work
|
||||||
|
this.getBoundingClientRect();
|
||||||
|
// Add active class to start slide-in animation
|
||||||
|
requestAnimationFrame(() => this.classList.add("active"));
|
||||||
|
// Call super.init() after rendering to ensure elements are available
|
||||||
|
super.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.teleportBack();
|
||||||
|
document.removeEventListener("turbo:before-cache", this.doClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapHeading(container, from, to) {
|
||||||
|
const headings = container.querySelectorAll(from);
|
||||||
|
headings.forEach((heading) => {
|
||||||
|
const newHeading = document.createElement(to);
|
||||||
|
newHeading.textContent = heading.textContent;
|
||||||
|
heading.replaceWith(newHeading);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
teleport() {
|
||||||
|
const content = this.querySelector(".modal-body");
|
||||||
|
const sidePanel = document.querySelector(".side-panel");
|
||||||
|
content.append(...sidePanel.children);
|
||||||
|
this.mapHeading(content, "h2", "h3");
|
||||||
|
}
|
||||||
|
|
||||||
|
teleportBack() {
|
||||||
|
const sidePanel = document.querySelector(".side-panel");
|
||||||
|
const content = this.querySelector(".modal-body");
|
||||||
|
sidePanel.append(...content.children);
|
||||||
|
this.mapHeading(sidePanel, "h3", "h2");
|
||||||
|
}
|
||||||
|
|
||||||
|
doClose() {
|
||||||
|
super.doClose();
|
||||||
|
|
||||||
|
// Try restore focus to drawer trigger
|
||||||
|
const restoreFocusElement =
|
||||||
|
document.querySelector("ld-filter-drawer-trigger") || document.body;
|
||||||
|
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-filter-drawer", FilterDrawer);
|
||||||
71
bookmarks/frontend/components/form.js
Normal file
71
bookmarks/frontend/components/form.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { HeadlessElement } from "../utils/element.js";
|
||||||
|
|
||||||
|
class Form extends HeadlessElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.addEventListener("keydown", this.onKeyDown);
|
||||||
|
this.addEventListener("change", this.onChange);
|
||||||
|
|
||||||
|
if (this.hasAttribute("data-form-reset")) {
|
||||||
|
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||||
|
// Useful for filter forms where navigating back would otherwise still show
|
||||||
|
// values from after the form submission, which means the filters would be out
|
||||||
|
// of sync with the URL.
|
||||||
|
this.initFormReset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this.hasAttribute("data-form-reset")) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(event) {
|
||||||
|
if (event.target.hasAttribute("data-submit-on-change")) {
|
||||||
|
this.querySelector("form")?.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Check for Ctrl/Cmd + Enter combination
|
||||||
|
if (
|
||||||
|
this.hasAttribute("data-submit-on-ctrl-enter") &&
|
||||||
|
event.key === "Enter" &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.querySelector("form")?.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initFormReset() {
|
||||||
|
this.controls = this.querySelectorAll("input, select");
|
||||||
|
this.controls.forEach((control) => {
|
||||||
|
if (control.type === "checkbox" || control.type === "radio") {
|
||||||
|
control.__initialValue = control.checked;
|
||||||
|
} else {
|
||||||
|
control.__initialValue = control.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.controls.forEach((control) => {
|
||||||
|
if (control.type === "checkbox" || control.type === "radio") {
|
||||||
|
control.checked = control.__initialValue;
|
||||||
|
} else {
|
||||||
|
control.value = control.__initialValue;
|
||||||
|
}
|
||||||
|
delete control.__initialValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-form", Form);
|
||||||
@@ -1,39 +1,27 @@
|
|||||||
import { Behavior } from "./index";
|
import { FocusTrapController } from "../utils/focus.js";
|
||||||
import { FocusTrapController } from "./focus-utils";
|
import { HeadlessElement } from "../utils/element.js";
|
||||||
|
|
||||||
export class ModalBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
|
export class Modal extends HeadlessElement {
|
||||||
|
init() {
|
||||||
this.onClose = this.onClose.bind(this);
|
this.onClose = this.onClose.bind(this);
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
|
||||||
this.overlay = element.querySelector(".modal-overlay");
|
this.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
||||||
this.closeButton = element.querySelector(".modal-header .close");
|
btn.addEventListener("click", this.onClose);
|
||||||
|
});
|
||||||
|
this.addEventListener("keydown", this.onKeyDown);
|
||||||
|
|
||||||
this.overlay.addEventListener("click", this.onClose);
|
|
||||||
this.closeButton.addEventListener("click", this.onClose);
|
|
||||||
document.addEventListener("keydown", this.onKeyDown);
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.overlay.removeEventListener("click", this.onClose);
|
|
||||||
this.closeButton.removeEventListener("click", this.onClose);
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
|
|
||||||
this.removeScrollLock();
|
|
||||||
this.focusTrap.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.setupScrollLock();
|
this.setupScrollLock();
|
||||||
this.focusTrap = new FocusTrapController(
|
this.focusTrap = new FocusTrapController(
|
||||||
this.element.querySelector(".modal-container"),
|
this.querySelector(".modal-container"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.removeScrollLock();
|
||||||
|
this.focusTrap.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
setupScrollLock() {
|
setupScrollLock() {
|
||||||
document.body.classList.add("scroll-lock");
|
document.body.classList.add("scroll-lock");
|
||||||
}
|
}
|
||||||
@@ -61,8 +49,8 @@ export class ModalBehavior extends Behavior {
|
|||||||
|
|
||||||
onClose(event) {
|
onClose(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.element.classList.add("closing");
|
this.classList.add("closing");
|
||||||
this.element.addEventListener(
|
this.addEventListener(
|
||||||
"animationend",
|
"animationend",
|
||||||
(event) => {
|
(event) => {
|
||||||
if (event.animationName === "fade-out") {
|
if (event.animationName === "fade-out") {
|
||||||
@@ -74,8 +62,17 @@ export class ModalBehavior extends Behavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
doClose() {
|
doClose() {
|
||||||
this.element.remove();
|
this.remove();
|
||||||
this.removeScrollLock();
|
this.dispatchEvent(new CustomEvent("modal:close"));
|
||||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
|
||||||
|
// Navigate to close URL
|
||||||
|
const closeUrl = this.dataset.closeUrl;
|
||||||
|
const frame = this.dataset.turboFrame;
|
||||||
|
const action = this.dataset.turboAction || "replace";
|
||||||
|
if (closeUrl) {
|
||||||
|
Turbo.visit(closeUrl, { action, frame: frame });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-modal", Modal);
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { html } from "lit";
|
||||||
import { SearchHistory } from "./SearchHistory.js";
|
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { cache } from "../cache.js";
|
import { TurboLitElement } from "../utils/element.js";
|
||||||
import {
|
import {
|
||||||
clampText,
|
clampText,
|
||||||
debounce,
|
debounce,
|
||||||
getCurrentWord,
|
getCurrentWord,
|
||||||
getCurrentWordBounds,
|
getCurrentWordBounds,
|
||||||
} from "../util.js";
|
} from "../utils/input.js";
|
||||||
|
import { PositionController } from "../utils/position-controller.js";
|
||||||
|
import { SearchHistory } from "../utils/search-history.js";
|
||||||
|
import { cache } from "../utils/tag-cache.js";
|
||||||
|
|
||||||
export class SearchAutocomplete extends LitElement {
|
export class SearchAutocomplete extends TurboLitElement {
|
||||||
static properties = {
|
static properties = {
|
||||||
name: { type: String },
|
inputName: { type: String, attribute: "input-name" },
|
||||||
placeholder: { type: String },
|
inputPlaceholder: { type: String, attribute: "input-placeholder" },
|
||||||
value: { type: String },
|
inputValue: { type: String, attribute: "input-value" },
|
||||||
mode: { type: String },
|
mode: { type: String },
|
||||||
search: { type: Object },
|
user: { type: String },
|
||||||
linkTarget: { type: String },
|
shared: { type: String },
|
||||||
|
unread: { type: String },
|
||||||
|
target: { type: String },
|
||||||
isFocus: { state: true },
|
isFocus: { state: true },
|
||||||
isOpen: { state: true },
|
isOpen: { state: true },
|
||||||
suggestions: { state: true },
|
suggestions: { state: true },
|
||||||
@@ -25,12 +29,11 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.name = "";
|
this.inputName = "";
|
||||||
this.placeholder = "";
|
this.inputPlaceholder = "";
|
||||||
this.value = "";
|
this.inputValue = "";
|
||||||
this.mode = "";
|
this.mode = "";
|
||||||
this.search = {};
|
this.target = "_blank";
|
||||||
this.linkTarget = "_blank";
|
|
||||||
this.isFocus = false;
|
this.isFocus = false;
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
this.suggestions = {
|
this.suggestions = {
|
||||||
@@ -41,20 +44,29 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
};
|
};
|
||||||
this.selectedIndex = undefined;
|
this.selectedIndex = undefined;
|
||||||
this.input = null;
|
this.input = null;
|
||||||
|
this.menu = null;
|
||||||
this.searchHistory = new SearchHistory();
|
this.searchHistory = new SearchHistory();
|
||||||
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
|
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
|
||||||
}
|
}
|
||||||
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
this.style.setProperty("--menu-max-height", "400px");
|
this.style.setProperty("--menu-max-height", "400px");
|
||||||
this.input = this.querySelector("input");
|
this.input = this.querySelector("input");
|
||||||
|
this.menu = this.querySelector(".menu");
|
||||||
// Track current search query after loading the page
|
// Track current search query after loading the page
|
||||||
this.searchHistory.pushCurrent();
|
this.searchHistory.pushCurrent();
|
||||||
this.updateSuggestions();
|
this.updateSuggestions();
|
||||||
|
this.positionController = new PositionController({
|
||||||
|
anchor: this.input,
|
||||||
|
overlay: this.menu,
|
||||||
|
autoWidth: true,
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFocus() {
|
handleFocus() {
|
||||||
@@ -67,7 +79,7 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleInput(e) {
|
handleInput(e) {
|
||||||
this.value = e.target.value;
|
this.inputValue = e.target.value;
|
||||||
this.debouncedLoadSuggestions();
|
this.debouncedLoadSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +117,14 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
|
|
||||||
open() {
|
open() {
|
||||||
this.isOpen = true;
|
this.isOpen = true;
|
||||||
|
this.positionController.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
this.updateSuggestions();
|
this.updateSuggestions();
|
||||||
this.selectedIndex = undefined;
|
this.selectedIndex = undefined;
|
||||||
|
this.positionController.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSuggestions() {
|
hasSuggestions() {
|
||||||
@@ -146,7 +160,7 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
|
|
||||||
// Recent search suggestions
|
// Recent search suggestions
|
||||||
const recentSearches = this.searchHistory
|
const recentSearches = this.searchHistory
|
||||||
.getRecentSearches(this.value, 5)
|
.getRecentSearches(this.inputValue, 5)
|
||||||
.map((value) => ({
|
.map((value) => ({
|
||||||
type: "search",
|
type: "search",
|
||||||
index: nextIndex(),
|
index: nextIndex(),
|
||||||
@@ -157,11 +171,13 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
// Bookmark suggestions
|
// Bookmark suggestions
|
||||||
let bookmarks = [];
|
let bookmarks = [];
|
||||||
|
|
||||||
if (this.value && this.value.length >= 3) {
|
if (this.inputValue && this.inputValue.length >= 3) {
|
||||||
const path = this.mode ? `/${this.mode}` : "";
|
const path = this.mode ? `/${this.mode}` : "";
|
||||||
const suggestionSearch = {
|
const suggestionSearch = {
|
||||||
...this.search,
|
user: this.user,
|
||||||
q: this.value,
|
shared: this.shared,
|
||||||
|
unread: this.unread,
|
||||||
|
q: this.inputValue,
|
||||||
};
|
};
|
||||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
|
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
|
||||||
limit: 5,
|
limit: 5,
|
||||||
@@ -203,11 +219,11 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
|
|
||||||
completeSuggestion(suggestion) {
|
completeSuggestion(suggestion) {
|
||||||
if (suggestion.type === "search") {
|
if (suggestion.type === "search") {
|
||||||
this.value = suggestion.value;
|
this.inputValue = suggestion.value;
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
if (suggestion.type === "bookmark") {
|
if (suggestion.type === "bookmark") {
|
||||||
window.open(suggestion.bookmark.url, this.linkTarget);
|
window.open(suggestion.bookmark.url, this.target);
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
if (suggestion.type === "tag") {
|
if (suggestion.type === "tag") {
|
||||||
@@ -277,10 +293,10 @@ export class SearchAutocomplete extends LitElement {
|
|||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
name="${this.name}"
|
name="${this.inputName}"
|
||||||
placeholder="${this.placeholder}"
|
placeholder="${this.inputPlaceholder}"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
.value="${this.value}"
|
.value="${this.inputValue}"
|
||||||
@input=${this.handleInput}
|
@input=${this.handleInput}
|
||||||
@keydown=${this.handleKeyDown}
|
@keydown=${this.handleKeyDown}
|
||||||
@focus=${this.handleFocus}
|
@focus=${this.handleFocus}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { cache } from "../cache.js";
|
import { TurboLitElement } from "../utils/element.js";
|
||||||
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
|
import { getCurrentWord, getCurrentWordBounds } from "../utils/input.js";
|
||||||
|
import { PositionController } from "../utils/position-controller.js";
|
||||||
|
import { cache } from "../utils/tag-cache.js";
|
||||||
|
|
||||||
export class TagAutocomplete extends LitElement {
|
export class TagAutocomplete extends TurboLitElement {
|
||||||
static properties = {
|
static properties = {
|
||||||
id: { type: String },
|
inputId: { type: String, attribute: "input-id" },
|
||||||
name: { type: String },
|
inputName: { type: String, attribute: "input-name" },
|
||||||
value: { type: String },
|
inputValue: { type: String, attribute: "input-value" },
|
||||||
placeholder: { type: String },
|
inputClass: { type: String, attribute: "input-class" },
|
||||||
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
|
inputPlaceholder: { type: String, attribute: "input-placeholder" },
|
||||||
|
inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" },
|
||||||
variant: { type: String },
|
variant: { type: String },
|
||||||
isFocus: { state: true },
|
isFocus: { state: true },
|
||||||
isOpen: { state: true },
|
isOpen: { state: true },
|
||||||
@@ -18,11 +21,11 @@ export class TagAutocomplete extends LitElement {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.id = "";
|
this.inputId = "";
|
||||||
this.name = "";
|
this.inputName = "";
|
||||||
this.value = "";
|
this.inputValue = "";
|
||||||
this.placeholder = "";
|
this.inputPlaceholder = "";
|
||||||
this.ariaDescribedBy = "";
|
this.inputAriaDescribedBy = "";
|
||||||
this.variant = "default";
|
this.variant = "default";
|
||||||
this.isFocus = false;
|
this.isFocus = false;
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
@@ -32,13 +35,20 @@ export class TagAutocomplete extends LitElement {
|
|||||||
this.suggestionList = null;
|
this.suggestionList = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
this.input = this.querySelector("input");
|
this.input = this.querySelector("input");
|
||||||
this.suggestionList = this.querySelector(".menu");
|
this.suggestionList = this.querySelector(".menu");
|
||||||
|
this.positionController = new PositionController({
|
||||||
|
anchor: this.input,
|
||||||
|
overlay: this.suggestionList,
|
||||||
|
autoWidth: true,
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFocus() {
|
handleFocus() {
|
||||||
@@ -92,12 +102,14 @@ export class TagAutocomplete extends LitElement {
|
|||||||
open() {
|
open() {
|
||||||
this.isOpen = true;
|
this.isOpen = true;
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
|
this.positionController.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
this.suggestions = [];
|
this.suggestions = [];
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
|
this.positionController.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
complete(suggestion) {
|
complete(suggestion) {
|
||||||
@@ -108,7 +120,7 @@ export class TagAutocomplete extends LitElement {
|
|||||||
suggestion.name +
|
suggestion.name +
|
||||||
" " +
|
" " +
|
||||||
value.substring(bounds.end);
|
value.substring(bounds.end);
|
||||||
this.input.dispatchEvent(new CustomEvent("change", { bubbles: true }));
|
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
|
||||||
|
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
@@ -145,15 +157,15 @@ export class TagAutocomplete extends LitElement {
|
|||||||
>
|
>
|
||||||
<!-- autocomplete real input box -->
|
<!-- autocomplete real input box -->
|
||||||
<input
|
<input
|
||||||
id="${this.id}"
|
id="${this.inputId || nothing}"
|
||||||
name="${this.name}"
|
name="${this.inputName || nothing}"
|
||||||
.value="${this.value || ""}"
|
.value="${this.inputValue || ""}"
|
||||||
placeholder="${this.placeholder || " "}"
|
placeholder="${this.inputPlaceholder || " "}"
|
||||||
class="form-input"
|
class="form-input ${this.inputClass || ""}"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
aria-describedby="${this.ariaDescribedBy}"
|
aria-describedby="${this.inputAriaDescribedBy || nothing}"
|
||||||
@input=${this.handleInput}
|
@input=${this.handleInput}
|
||||||
@keydown=${this.handleKeyDown}
|
@keydown=${this.handleKeyDown}
|
||||||
@focus=${this.handleFocus}
|
@focus=${this.handleFocus}
|
||||||
31
bookmarks/frontend/components/upload-button.js
Normal file
31
bookmarks/frontend/components/upload-button.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { HeadlessElement } from "../utils/element.js";
|
||||||
|
|
||||||
|
class UploadButton extends HeadlessElement {
|
||||||
|
init() {
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
|
||||||
|
this.button = this.querySelector('button[type="submit"]');
|
||||||
|
this.button.addEventListener("click", this.onClick);
|
||||||
|
|
||||||
|
this.fileInput = this.querySelector('input[type="file"]');
|
||||||
|
this.fileInput.addEventListener("change", this.onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange() {
|
||||||
|
// Check if the file input has a file selected
|
||||||
|
if (!this.fileInput.files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closest("form").requestSubmit(this.button);
|
||||||
|
// remove selected file so it doesn't get submitted again
|
||||||
|
this.fileInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-upload-button", UploadButton);
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import "@hotwired/turbo";
|
import "@hotwired/turbo";
|
||||||
import "./behaviors/bookmark-page";
|
import "./components/bookmark-page.js";
|
||||||
import "./behaviors/bulk-edit";
|
import "./components/clear-button.js";
|
||||||
import "./behaviors/clear-button";
|
import "./components/confirm-dropdown.js";
|
||||||
import "./behaviors/confirm-button";
|
import "./components/details-modal.js";
|
||||||
import "./behaviors/details-modal";
|
import "./components/dev-tool.js";
|
||||||
import "./behaviors/dropdown";
|
import "./components/dropdown.js";
|
||||||
import "./behaviors/filter-drawer";
|
import "./components/filter-drawer.js";
|
||||||
import "./behaviors/form";
|
import "./components/form.js";
|
||||||
import "./behaviors/global-shortcuts";
|
import "./components/modal.js";
|
||||||
import "./behaviors/search-autocomplete";
|
import "./components/search-autocomplete.js";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./components/tag-autocomplete.js";
|
||||||
|
import "./components/upload-button.js";
|
||||||
export { api } from "./api";
|
import "./shortcuts.js";
|
||||||
export { cache } from "./cache";
|
|
||||||
|
|||||||
62
bookmarks/frontend/shortcuts.js
Normal file
62
bookmarks/frontend/shortcuts.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget =
|
||||||
|
targetNodeName === "INPUT" ||
|
||||||
|
targetNodeName === "SELECT" ||
|
||||||
|
targetNodeName === "TEXTAREA";
|
||||||
|
|
||||||
|
if (isInputTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcuts for navigating bookmarks with arrow keys
|
||||||
|
const isArrowUp = event.key === "ArrowUp";
|
||||||
|
const isArrowDown = event.key === "ArrowDown";
|
||||||
|
if (isArrowUp || isArrowDown) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Detect current bookmark list item
|
||||||
|
const items = [...document.querySelectorAll("ul.bookmark-list > li")];
|
||||||
|
const path = event.composedPath();
|
||||||
|
const currentItem = path.find((item) => items.includes(item));
|
||||||
|
|
||||||
|
// Find next item
|
||||||
|
let nextItem;
|
||||||
|
if (currentItem) {
|
||||||
|
nextItem = isArrowUp
|
||||||
|
? currentItem.previousElementSibling
|
||||||
|
: currentItem.nextElementSibling;
|
||||||
|
} else {
|
||||||
|
// Select first item
|
||||||
|
nextItem = items[0];
|
||||||
|
}
|
||||||
|
// Focus first link
|
||||||
|
if (nextItem) {
|
||||||
|
nextItem.querySelector("a").focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcut for toggling all notes
|
||||||
|
if (event.key === "e") {
|
||||||
|
const list = document.querySelector(".bookmark-list");
|
||||||
|
if (list) {
|
||||||
|
list.classList.toggle("show-notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcut for focusing search input
|
||||||
|
if (event.key === "s") {
|
||||||
|
const searchInput = document.querySelector('input[type="search"]');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcut for adding new bookmark
|
||||||
|
if (event.key === "n") {
|
||||||
|
window.location.assign("/bookmarks/new");
|
||||||
|
}
|
||||||
|
});
|
||||||
82
bookmarks/frontend/utils/element.js
Normal file
82
bookmarks/frontend/utils/element.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { LitElement } from "lit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for custom elements that wrap existing server-rendered DOM.
|
||||||
|
*
|
||||||
|
* Handles timing issues where connectedCallback fires before child elements
|
||||||
|
* are parsed during initial page load. With Turbo navigation, children are
|
||||||
|
* always available, but on fresh page loads they may not be.
|
||||||
|
*
|
||||||
|
* Subclasses should override init() instead of connectedCallback().
|
||||||
|
*/
|
||||||
|
export class HeadlessElement extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
if (this.__initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.__initialized = true;
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("turbo:load", () => this.init(), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Override in subclass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isTopFrameVisit = false;
|
||||||
|
|
||||||
|
document.addEventListener("turbo:visit", (event) => {
|
||||||
|
const url = event.detail.url;
|
||||||
|
isTopFrameVisit =
|
||||||
|
document.querySelector(`turbo-frame[src="${url}"][target="_top"]`) !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("turbo:render", () => {
|
||||||
|
isTopFrameVisit = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("turbo:before-morph-element", (event) => {
|
||||||
|
const parent = event.target?.parentElement;
|
||||||
|
if (parent instanceof TurboLitElement) {
|
||||||
|
// Prevent Turbo from morphing Lit elements contents, which would remove
|
||||||
|
// elements rendered on the client side.
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export class TurboLitElement extends LitElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.__prepareForCache = this.__prepareForCache.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRenderRoot() {
|
||||||
|
return this; // Render to light DOM
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
document.addEventListener("turbo:before-cache", this.__prepareForCache);
|
||||||
|
super.connectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener("turbo:before-cache", this.__prepareForCache);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
__prepareForCache() {
|
||||||
|
// Remove rendered contents before caching, otherwise restoring the DOM from
|
||||||
|
// cache will result in duplicated contents. Turbo also fires before-cache
|
||||||
|
// when rendering a frame that does target the top frame, in which case we
|
||||||
|
// want to keep the contents.
|
||||||
|
if (!isTopFrameVisit) {
|
||||||
|
this.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,6 @@ export function debounce(callback, delay = 250) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function preventDefault(fn) {
|
|
||||||
return function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
fn.call(this, event);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampText(text, maxChars = 30) {
|
export function clampText(text, maxChars = 30) {
|
||||||
if (!text || text.length <= 30) return text;
|
if (!text || text.length <= 30) return text;
|
||||||
|
|
||||||
71
bookmarks/frontend/utils/position-controller.js
Normal file
71
bookmarks/frontend/utils/position-controller.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
arrow,
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
|
||||||
|
export class PositionController {
|
||||||
|
constructor(options) {
|
||||||
|
this.anchor = options.anchor;
|
||||||
|
this.overlay = options.overlay;
|
||||||
|
this.arrow = options.arrow;
|
||||||
|
this.placement = options.placement || "bottom";
|
||||||
|
this.offset = options.offset;
|
||||||
|
this.autoWidth = options.autoWidth || false;
|
||||||
|
this.autoUpdateCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
if (!this.autoUpdateCleanup) {
|
||||||
|
this.autoUpdateCleanup = autoUpdate(this.anchor, this.overlay, () =>
|
||||||
|
this.updatePosition(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
if (this.autoUpdateCleanup) {
|
||||||
|
this.autoUpdateCleanup();
|
||||||
|
this.autoUpdateCleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition() {
|
||||||
|
const middleware = [flip(), shift()];
|
||||||
|
if (this.arrow) {
|
||||||
|
middleware.push(arrow({ element: this.arrow }));
|
||||||
|
}
|
||||||
|
if (this.offset) {
|
||||||
|
middleware.push(offset(this.offset));
|
||||||
|
}
|
||||||
|
computePosition(this.anchor, this.overlay, {
|
||||||
|
placement: this.placement,
|
||||||
|
strategy: "fixed",
|
||||||
|
middleware,
|
||||||
|
}).then(({ x, y, placement, middlewareData }) => {
|
||||||
|
Object.assign(this.overlay.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overlay.classList.remove("top-aligned", "bottom-aligned");
|
||||||
|
this.overlay.classList.add(`${placement}-aligned`);
|
||||||
|
|
||||||
|
if (this.arrow) {
|
||||||
|
const { x, y } = middlewareData.arrow;
|
||||||
|
Object.assign(this.arrow.style, {
|
||||||
|
left: x != null ? `${x}px` : "",
|
||||||
|
top: y != null ? `${y}px` : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.autoWidth) {
|
||||||
|
const width = this.anchor.offsetWidth;
|
||||||
|
this.overlay.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "./api.js";
|
import { api } from "../api.js";
|
||||||
|
|
||||||
class Cache {
|
class TagCache {
|
||||||
constructor(api) {
|
constructor(api) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
|
|
||||||
@@ -32,4 +32,4 @@ class Cache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cache = new Cache(api);
|
export const cache = new TagCache(api);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import os
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.utils import get_random_secret_key
|
from django.core.management.utils import get_random_secret_key
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -15,10 +14,10 @@ class Command(BaseCommand):
|
|||||||
secret_key_file = os.path.join("data", "secretkey.txt")
|
secret_key_file = os.path.join("data", "secretkey.txt")
|
||||||
|
|
||||||
if os.path.exists(secret_key_file):
|
if os.path.exists(secret_key_file):
|
||||||
logger.info(f"Secret key file already exists")
|
logger.info("Secret key file already exists")
|
||||||
return
|
return
|
||||||
|
|
||||||
secret_key = get_random_secret_key()
|
secret_key = get_random_secret_key()
|
||||||
with open(secret_key_file, "w") as f:
|
with open(secret_key_file, "w") as f:
|
||||||
f.write(secret_key)
|
f.write(secret_key)
|
||||||
logger.info(f"Generated secret key file")
|
logger.info("Generated secret key file")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import importlib
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
|
||||||
from bookmarks.models import UserProfile, GlobalSettings
|
from bookmarks.models import GlobalSettings, UserProfile
|
||||||
|
|
||||||
|
|
||||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||||
@@ -22,7 +22,7 @@ class LinkdingMiddleware:
|
|||||||
# add global settings to request
|
# add global settings to request
|
||||||
try:
|
try:
|
||||||
global_settings = GlobalSettings.get()
|
global_settings = GlobalSettings.get()
|
||||||
except:
|
except Exception:
|
||||||
global_settings = default_global_settings
|
global_settings = default_global_settings
|
||||||
request.global_settings = global_settings
|
request.global_settings = global_settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# Generated by Django 2.2.2 on 2019-06-28 23:49
|
# Generated by Django 2.2.2 on 2019-06-28 23:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# Generated by Django 2.2.2 on 2019-06-29 23:03
|
# Generated by Django 2.2.2 on 2019-06-29 23:03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
("bookmarks", "0001_initial"),
|
("bookmarks", "0001_initial"),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0002_auto_20190629_2303"),
|
("bookmarks", "0002_auto_20190629_2303"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0003_auto_20200913_0656"),
|
("bookmarks", "0003_auto_20200913_0656"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Generated by Django 2.2.13 on 2021-01-03 12:12
|
# Generated by Django 2.2.13 on 2021-01-03 12:12
|
||||||
|
|
||||||
import bookmarks.validators
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import bookmarks.validators
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0004_auto_20200926_1028"),
|
("bookmarks", "0004_auto_20200926_1028"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0005_auto_20210103_1212"),
|
("bookmarks", "0005_auto_20210103_1212"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Generated by Django 2.2.18 on 2021-03-26 22:39
|
# Generated by Django 2.2.18 on 2021-03-26 22:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps, schema_editor):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0007_userprofile"),
|
("bookmarks", "0007_userprofile"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0008_userprofile_bookmark_date_display"),
|
("bookmarks", "0008_userprofile_bookmark_date_display"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
|
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0010_userprofile_bookmark_link_target"),
|
("bookmarks", "0010_userprofile_bookmark_link_target"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# Generated by Django 3.2.6 on 2022-01-08 19:24
|
# Generated by Django 3.2.6 on 2022-01-08 19:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
("bookmarks", "0011_userprofile_web_archive_integration"),
|
("bookmarks", "0011_userprofile_web_archive_integration"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 3.2.6 on 2022-01-08 19:27
|
# Generated by Django 3.2.6 on 2022-01-08 19:27
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
from bookmarks.models import Toast
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# Generated by Django 3.2.13 on 2022-07-23 20:35
|
# Generated by Django 3.2.13 on 2022-07-23 20:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
("bookmarks", "0014_alter_bookmark_unread"),
|
("bookmarks", "0014_alter_bookmark_unread"),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0015_feedtoken"),
|
("bookmarks", "0015_feedtoken"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0016_bookmark_shared"),
|
("bookmarks", "0016_bookmark_shared"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0017_userprofile_enable_sharing"),
|
("bookmarks", "0017_userprofile_enable_sharing"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0018_bookmark_favicon_file"),
|
("bookmarks", "0018_bookmark_favicon_file"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0019_userprofile_enable_favicons"),
|
("bookmarks", "0019_userprofile_enable_favicons"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0020_userprofile_tag_search"),
|
("bookmarks", "0020_userprofile_tag_search"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0021_userprofile_display_url"),
|
("bookmarks", "0021_userprofile_display_url"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0022_bookmark_notes"),
|
("bookmarks", "0022_bookmark_notes"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0023_userprofile_permanent_notes"),
|
("bookmarks", "0023_userprofile_permanent_notes"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0024_userprofile_enable_public_sharing"),
|
("bookmarks", "0024_userprofile_enable_public_sharing"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0025_userprofile_search_preferences"),
|
("bookmarks", "0025_userprofile_search_preferences"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0026_userprofile_custom_css"),
|
("bookmarks", "0026_userprofile_custom_css"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
|
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 5.0.2 on 2024-03-29 21:25
|
# Generated by Django 5.0.2 on 2024-03-29 21:25
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
from bookmarks.models import Toast
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ User = get_user_model()
|
|||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps, schema_editor):
|
||||||
|
|
||||||
for user in User.objects.all():
|
for user in User.objects.all():
|
||||||
toast = Toast(
|
toast = Toast(
|
||||||
key="bookmark_list_actions_hint",
|
key="bookmark_list_actions_hint",
|
||||||
@@ -24,7 +23,6 @@ def reverse(apps, schema_editor):
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
|
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0029_bookmark_list_actions_toast"),
|
("bookmarks", "0029_bookmark_list_actions_toast"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0030_bookmarkasset"),
|
("bookmarks", "0030_bookmarkasset"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 5.0.2 on 2024-04-01 12:17
|
# Generated by Django 5.0.2 on 2024-04-01 12:17
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
from bookmarks.models import Toast
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ User = get_user_model()
|
|||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps, schema_editor):
|
||||||
|
|
||||||
for user in User.objects.all():
|
for user in User.objects.all():
|
||||||
toast = Toast(
|
toast = Toast(
|
||||||
key="html_snapshots_hint",
|
key="html_snapshots_hint",
|
||||||
@@ -24,7 +23,6 @@ def reverse(apps, schema_editor):
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
|
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0032_html_snapshots_hint_toast"),
|
("bookmarks", "0032_html_snapshots_hint_toast"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0033_userprofile_default_mark_unread"),
|
("bookmarks", "0033_userprofile_default_mark_unread"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0035_userprofile_tag_grouping"),
|
("bookmarks", "0035_userprofile_tag_grouping"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0036_userprofile_auto_tagging_rules"),
|
("bookmarks", "0036_userprofile_auto_tagging_rules"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0037_globalsettings"),
|
("bookmarks", "0037_globalsettings"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0038_globalsettings_guest_profile_user"),
|
("bookmarks", "0038_globalsettings_guest_profile_user"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
|
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from bookmarks.models import Bookmark
|
|||||||
def forwards(apps, schema_editor):
|
def forwards(apps, schema_editor):
|
||||||
Bookmark.objects.filter(
|
Bookmark.objects.filter(
|
||||||
Q(title__isnull=True) | Q(title__exact=""),
|
Q(title__isnull=True) | Q(title__exact=""),
|
||||||
).extra(
|
).extra(where=["website_title IS NOT NULL"]).update(
|
||||||
where=["website_title IS NOT NULL"]
|
title=RawSQL("website_title", ())
|
||||||
).update(title=RawSQL("website_title", ()))
|
)
|
||||||
|
|
||||||
Bookmark.objects.filter(
|
Bookmark.objects.filter(
|
||||||
Q(description__isnull=True) | Q(description__exact=""),
|
Q(description__isnull=True) | Q(description__exact=""),
|
||||||
@@ -26,7 +26,6 @@ def reverse(apps, schema_editor):
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0040_userprofile_items_per_page_and_more"),
|
("bookmarks", "0040_userprofile_items_per_page_and_more"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0041_merge_metadata"),
|
("bookmarks", "0041_merge_metadata"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0042_userprofile_custom_css_hash"),
|
("bookmarks", "0042_userprofile_custom_css_hash"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ def reverse(apps, schema_editor):
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0043_userprofile_collapse_side_panel"),
|
("bookmarks", "0043_userprofile_collapse_side_panel"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0044_bookmark_latest_snapshot"),
|
("bookmarks", "0044_bookmark_latest_snapshot"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
|
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
|
||||||
]
|
]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user