Compare commits
162 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c746afcf76 | ||
![]() |
aaa0f6e119 | ||
![]() |
cd215a9237 | ||
![]() |
1e56b0e6f3 | ||
![]() |
5cc8c9c010 | ||
![]() |
846808d870 | ||
![]() |
6d9a694756 | ||
![]() |
de38e56b3f | ||
![]() |
c6fb695af2 | ||
![]() |
93faf70b37 | ||
![]() |
5330252db9 | ||
![]() |
ef00d289f5 | ||
![]() |
4e8318d0ae | ||
![]() |
a8623d11ef | ||
![]() |
8cd992ca30 | ||
![]() |
68c104ba54 | ||
![]() |
7a4236d179 | ||
![]() |
e87304501f | ||
![]() |
809e9e02f3 | ||
![]() |
2bb33ff96d | ||
![]() |
549554cc17 | ||
![]() |
20e31397cc | ||
![]() |
94ae5fb41c | ||
![]() |
2a550e2315 | ||
![]() |
a79e8bcd59 | ||
![]() |
1710d44df7 | ||
![]() |
9967b3e27b | ||
![]() |
1672dc0152 | ||
![]() |
8be72a5d1f | ||
![]() |
bb796c9bdb | ||
![]() |
578680c3c1 | ||
![]() |
8debb5c5aa | ||
![]() |
be752f8146 | ||
![]() |
e487cf726a | ||
![]() |
f2800efc1a | ||
![]() |
9a00ae4b93 | ||
![]() |
da9371e33c | ||
![]() |
5b3f2f6563 | ||
![]() |
04065f8079 | ||
![]() |
d986ff0900 | ||
![]() |
51a85bbaf1 | ||
![]() |
39b911880d | ||
![]() |
9db3fa1248 | ||
![]() |
77689366a0 | ||
![]() |
f2e6014ca4 | ||
![]() |
da98929f07 | ||
![]() |
1b0684bd6c | ||
![]() |
8928c78530 | ||
![]() |
61108234b4 | ||
![]() |
7b098d4549 | ||
![]() |
648e67bd91 | ||
![]() |
6bba4f35c8 | ||
![]() |
6d9d0e19f1 | ||
![]() |
a23c357f2f | ||
![]() |
f1acb4f7c9 | ||
![]() |
48fc499aed | ||
![]() |
2a55800e18 | ||
![]() |
e45dffb9cb | ||
![]() |
226eb69f8b | ||
![]() |
b9bee24047 | ||
![]() |
9dfc9b03b4 | ||
![]() |
6ab6a031c7 | ||
![]() |
1a1092d03a | ||
![]() |
4260dfce79 | ||
![]() |
2d3bd13a12 | ||
![]() |
b037de14c9 | ||
![]() |
bbf173c135 | ||
![]() |
002fec37d0 | ||
![]() |
996e2b6e19 | ||
![]() |
6838e45e99 | ||
![]() |
5b2a2c2b0d | ||
![]() |
988468f3e5 | ||
![]() |
3ac0503843 | ||
![]() |
6d3755f46a | ||
![]() |
25342e5fb6 | ||
![]() |
be548a95a0 | ||
![]() |
978fba4cf5 | ||
![]() |
8a3572ba4b | ||
![]() |
b21812c30a | ||
![]() |
72fbf6a590 | ||
![]() |
31ac796d6d | ||
![]() |
2d81ea6f6e | ||
![]() |
2e97b13bad | ||
![]() |
30f85103cd | ||
![]() |
cfe4ff113d | ||
![]() |
757dc56277 | ||
![]() |
dfbb367857 | ||
![]() |
2276832465 | ||
![]() |
9d61bdce52 | ||
![]() |
1274a9ae0a | ||
![]() |
5e7172d17e | ||
![]() |
78608135d9 | ||
![]() |
51acd1da3f | ||
![]() |
016ff2da66 | ||
![]() |
77d7e6e66a | ||
![]() |
c5a300a435 | ||
![]() |
0d4c47eb81 | ||
![]() |
17442eeb9a | ||
![]() |
2973812626 | ||
![]() |
fc48b266a8 | ||
![]() |
7b42241026 | ||
![]() |
9c648dc67f | ||
![]() |
1624128132 | ||
![]() |
d1dd85538b | ||
![]() |
c5aab3886e | ||
![]() |
3f2739e5a6 | ||
![]() |
f1ed89a0ba | ||
![]() |
a59a7a777c | ||
![]() |
9a5c535872 | ||
![]() |
e6ebca1436 | ||
![]() |
085d67e9f4 | ||
![]() |
68825444fb | ||
![]() |
b2ca16ec9c | ||
![]() |
649f4154e5 | ||
![]() |
d2e8a95e3c | ||
![]() |
c3149409b0 | ||
![]() |
4626fa1c67 | ||
![]() |
6548e16baa | ||
![]() |
c177de164a | ||
![]() |
e9ecad38ac | ||
![]() |
621aedd8eb | ||
![]() |
4187141ac8 | ||
![]() |
cf0cc32090 | ||
![]() |
1f2cf21585 | ||
![]() |
0dd05b9269 | ||
![]() |
5cd6d773db | ||
![]() |
d4c348cc5a | ||
![]() |
791a5c73ca | ||
![]() |
ebed0c050d | ||
![]() |
f4dd2b53b5 | ||
![]() |
b53fe09c39 | ||
![]() |
ff88e726cc | ||
![]() |
52400feacf | ||
![]() |
c93709b549 | ||
![]() |
ba904ed191 | ||
![]() |
d1f81fee0e | ||
![]() |
7b405c054d | ||
![]() |
23ad52f75d | ||
![]() |
c3a2305a5f | ||
![]() |
d4006026db | ||
![]() |
70bdf88791 | ||
![]() |
93e2832a89 | ||
![]() |
f5708594a7 | ||
![]() |
67f237c1de | ||
![]() |
95f489ea48 | ||
![]() |
ed57da3c99 | ||
![]() |
c5c5949d20 | ||
![]() |
f4e66c1ff1 | ||
![]() |
fe7ddbe645 | ||
![]() |
afa57aa10b | ||
![]() |
b4108c9a56 | ||
![]() |
6cf5fb396a | ||
![]() |
3d8866c7bc | ||
![]() |
8544137a31 | ||
![]() |
baa3d5596d | ||
![]() |
f79c24453c | ||
![]() |
f3c1101746 | ||
![]() |
ceceb56164 | ||
![]() |
450980a8d4 | ||
![]() |
2aab2813f4 | ||
![]() |
0e488b7ce3 | ||
![]() |
53e4aeb1c1 |
@@ -2,7 +2,7 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
|
@@ -3,7 +3,6 @@
|
||||
|
||||
# Include files required for build or at runtime
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
@@ -19,4 +18,4 @@
|
||||
!/version.txt
|
||||
|
||||
# Remove dev settings
|
||||
/siteroot/settings/dev.py
|
||||
/bookmarks/settings/dev.py
|
||||
|
89
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from file
|
||||
id: get_version
|
||||
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build latest
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest
|
||||
sissbruecker/linkding:${{ env.VERSION }}
|
||||
ghcr.io/sissbruecker/linkding:latest
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
|
||||
target: linkding
|
||||
push: true
|
||||
|
||||
- name: Build latest-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||
ghcr.io/sissbruecker/linkding:latest-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||
target: linkding
|
||||
push: true
|
||||
|
||||
- name: Build latest-plus
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-plus
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||
ghcr.io/sissbruecker/linkding:latest-plus
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||
target: linkding-plus
|
||||
push: true
|
||||
|
||||
- name: Build latest-plus-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-plus-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:latest-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
target: linkding-plus
|
||||
push: true
|
6
.github/workflows/main.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -55,4 +55,4 @@ jobs:
|
||||
npm run build
|
||||
python manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||
|
2
.gitignore
vendored
@@ -192,7 +192,7 @@ typings/
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/uBOLite.chromium.mv3
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
216
CHANGELOG.md
@@ -1,5 +1,221 @@
|
||||
# Changelog
|
||||
|
||||
## v1.41.0 (19/06/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
|
||||
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
|
||||
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
|
||||
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
|
||||
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
|
||||
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
|
||||
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
|
||||
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
|
||||
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
|
||||
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
|
||||
|
||||
### New Contributors
|
||||
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
|
||||
|
||||
---
|
||||
|
||||
## v1.40.0 (17/05/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
|
||||
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
|
||||
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
|
||||
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
|
||||
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
|
||||
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
|
||||
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
|
||||
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
|
||||
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
|
||||
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
|
||||
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
|
||||
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
|
||||
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
|
||||
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
|
||||
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
|
||||
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
|
||||
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
|
||||
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
|
||||
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
|
||||
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
|
||||
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
|
||||
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
|
||||
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
|
||||
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
|
||||
|
||||
### New Contributors
|
||||
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
|
||||
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
|
||||
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
|
||||
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
|
||||
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
|
||||
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
|
||||
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
|
||||
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
|
||||
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
|
||||
|
||||
---
|
||||
|
||||
## v1.39.1 (06/03/2025)
|
||||
|
||||
> [!WARNING]
|
||||
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
|
||||
|
||||
---
|
||||
|
||||
## v1.39.0 (06/03/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
|
||||
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
|
||||
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
|
||||
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
|
||||
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
|
||||
|
||||
### New Contributors
|
||||
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
|
||||
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
|
||||
|
||||
---
|
||||
|
||||
## v1.38.1 (22/02/2025)
|
||||
|
||||
### What's Changed
|
||||
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
|
||||
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
|
||||
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
|
||||
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
|
||||
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
|
||||
|
||||
### New Contributors
|
||||
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
|
||||
|
||||
---
|
||||
|
||||
## v1.38.0 (09/02/2025)
|
||||
|
||||
### What's Changed
|
||||
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
|
||||
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
|
||||
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
|
||||
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
|
||||
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
|
||||
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
|
||||
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
|
||||
|
||||
### New Contributors
|
||||
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
|
||||
|
||||
---
|
||||
|
||||
## v1.37.0 (26/01/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
|
||||
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
|
||||
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
|
||||
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
|
||||
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
|
||||
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
|
||||
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
|
||||
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
|
||||
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
|
||||
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
|
||||
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
|
||||
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
|
||||
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
|
||||
|
||||
### New Contributors
|
||||
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
|
||||
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
|
||||
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
|
||||
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
|
||||
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
|
||||
|
||||
---
|
||||
|
||||
## v1.36.0 (02/10/2024)
|
||||
|
||||
### What's Changed
|
||||
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
|
||||
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
|
||||
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
|
||||
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
|
||||
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
|
||||
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
|
||||
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
|
||||
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
|
||||
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
|
||||
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
|
||||
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
|
||||
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
|
||||
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
|
||||
|
||||
### New Contributors
|
||||
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
|
||||
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
|
||||
|
||||
---
|
||||
|
||||
## v1.35.0 (23/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
|
||||
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
|
||||
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
|
||||
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
|
||||
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
|
||||
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
|
||||
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
|
||||
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
|
||||
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
|
||||
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
|
||||
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
|
||||
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
|
||||
|
||||
### New Contributors
|
||||
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
|
||||
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
|
||||
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
|
||||
|
||||
---
|
||||
|
||||
## v1.34.0 (16/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825
|
||||
* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829
|
||||
* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826
|
||||
|
||||
### New Contributors
|
||||
* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0
|
||||
|
||||
---
|
||||
|
||||
## v1.33.0 (14/09/2024)
|
||||
|
||||
### What's Changed
|
||||
|
4
Makefile
@@ -4,12 +4,12 @@ serve:
|
||||
python manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py process_tasks
|
||||
python manage.py run_huey
|
||||
|
||||
test:
|
||||
pytest -n auto
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
black siteroot
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
||||
|
232
README.md
@@ -1,25 +1,11 @@
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://github.com/sissbruecker/linkding">
|
||||
<img src="docs/header.svg" height="50">
|
||||
<img src="assets/header.svg" height="50">
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
- [Introduction](#introduction)
|
||||
- [Installation](#installation)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Docker Compose](#using-docker-compose)
|
||||
- [User Setup](#user-setup)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Managed Hosting Options](#managed-hosting-options)
|
||||
- [Documentation](#documentation)
|
||||
- [Browser Extension](#browser-extension)
|
||||
- [Community](#community)
|
||||
- [Acknowledgements + Donations](#acknowledgements--donations)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
|
||||
linkding is a bookmark manager that you can host yourself.
|
||||
@@ -49,219 +35,33 @@ The name comes from:
|
||||
|
||||
**Screenshot:**
|
||||
|
||||

|
||||

|
||||
|
||||
## Installation
|
||||
## Getting Started
|
||||
|
||||
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
|
||||
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
|
||||
linkding uses an SQLite database by default.
|
||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||
|
||||
### Using Docker
|
||||
|
||||
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>latest</code></td>
|
||||
<td>Provides the basic functionality of linkding</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-plus</code></td>
|
||||
<td>
|
||||
Includes feature for archiving websites as HTML snapshots
|
||||
<ul>
|
||||
<li>Significantly larger image size as it includes a Chromium installation</li>
|
||||
<li>Requires more runtime memory to run Chromium</li>
|
||||
<li>Requires more disk space for storing HTML snapshots</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-alpine</code></td>
|
||||
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-plus-alpine</code></td>
|
||||
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
|
||||
|
||||
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
|
||||
|
||||
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### User Setup
|
||||
|
||||
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
**Docker Compose**
|
||||
```shell
|
||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
|
||||
|
||||
<details>
|
||||
<summary>Apache</summary>
|
||||
|
||||
Apache2 does not change the headers by default, and should not
|
||||
need additional configuration.
|
||||
|
||||
An example virtual host that proxies to linkding might look like:
|
||||
```
|
||||
<VirtualHost *:9100>
|
||||
<Proxy *>
|
||||
Order deny,allow
|
||||
Allow from all
|
||||
</Proxy>
|
||||
|
||||
ProxyPass / http://linkding:9090/
|
||||
ProxyPassReverse / http://linkding:9090/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
|
||||
|
||||
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Caddy 2</summary>
|
||||
|
||||
Caddy does not change the headers by default, and should not need any further configuration.
|
||||
|
||||
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
|
||||
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
|
||||
```
|
||||
location /linkding {
|
||||
...
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
### Managed Hosting Options
|
||||
|
||||
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
|
||||
|
||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
||||
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
||||
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
|
||||
The following links help you to get started with linkding:
|
||||
- [Install linkding on your own server](https://linkding.link/installation) or [check managed hosting options](https://linkding.link/managed-hosting)
|
||||
- [Install the browser extension](https://linkding.link/browser-extension)
|
||||
- [Check out community projects](https://linkding.link/community), which include mobile apps, browser extensions, libraries and more
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
|
||||
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
||||
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
|
||||
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
|
||||
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
|
||||
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
|
||||
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||
The full documentation is now available at [linkding.link](https://linkding.link/).
|
||||
|
||||
## Browser Extension
|
||||
If you want to contribute to the documentation, you can find the source files in the `docs` folder.
|
||||
|
||||
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
|
||||
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
|
||||
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
If you want to contribute a community project, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md).
|
||||
|
||||
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
||||
## Contributing
|
||||
|
||||
## Community
|
||||
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements + Donations
|
||||
|
||||
### PikaPods
|
||||
|
||||
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
|
||||
|
||||
See the table below for a list of donations.
|
||||
|
||||
| Source | Description | Amount | Donated to |
|
||||
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
|
||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
|
||||
|
||||
### JetBrains
|
||||
|
||||
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||
Small improvements, bugfixes and documentation improvements are always welcome. If you want to contribute a larger feature, consider opening an issue first to discuss it. I may choose to ignore PRs for features that don't align with the project's goals or that I don't want to maintain.
|
||||
|
||||
## Development
|
||||
|
||||
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.10
|
||||
- Python 3.12
|
||||
- Node.js
|
||||
|
||||
### Setup
|
||||
@@ -305,7 +105,7 @@ The frontend is now available under http://localhost:8000
|
||||
|
||||
Run all tests with pytest:
|
||||
```
|
||||
pytest
|
||||
make test
|
||||
```
|
||||
|
||||
### Formatting
|
||||
@@ -317,7 +117,7 @@ make format
|
||||
|
||||
### DevContainers
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@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:
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
41
assets/header.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 2126 591" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1265.31,-1395.82)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.823127,0,0,0.823127,-786.171,-888.198)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||
<g transform="matrix(50,0,0,50,770.835,299.13)">
|
||||
<rect x="0.064" y="-0.716" width="0.088" height="0.716" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,782.693,299.13)">
|
||||
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,794.552,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,823.109,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.716L0.154,-0.716L0.154,-0.308L0.362,-0.519L0.476,-0.519L0.278,-0.326L0.496,-0L0.388,-0L0.216,-0.265L0.154,-0.206L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,848.859,299.13)">
|
||||
<path d="M0.402,-0L0.402,-0.065C0.369,-0.014 0.321,0.012 0.257,0.012C0.216,0.012 0.178,0 0.143,-0.022C0.109,-0.045 0.082,-0.077 0.063,-0.118C0.044,-0.159 0.034,-0.206 0.034,-0.259C0.034,-0.311 0.043,-0.357 0.06,-0.399C0.077,-0.442 0.103,-0.474 0.138,-0.497C0.172,-0.519 0.211,-0.53 0.253,-0.53C0.285,-0.53 0.313,-0.524 0.337,-0.51C0.361,-0.497 0.381,-0.48 0.396,-0.459L0.396,-0.716L0.484,-0.716L0.484,-0L0.402,-0ZM0.125,-0.259C0.125,-0.192 0.139,-0.143 0.167,-0.11C0.194,-0.077 0.228,-0.061 0.266,-0.061C0.304,-0.061 0.337,-0.076 0.363,-0.107C0.39,-0.139 0.404,-0.187 0.404,-0.251C0.404,-0.322 0.39,-0.375 0.363,-0.408C0.335,-0.441 0.302,-0.458 0.262,-0.458C0.223,-0.458 0.19,-0.442 0.164,-0.41C0.138,-0.378 0.125,-0.327 0.125,-0.259Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,877.417,299.13)">
|
||||
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,889.275,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,917.833,299.13)">
|
||||
<path d="M0.05,0.043L0.135,0.056C0.139,0.082 0.149,0.101 0.165,0.113C0.187,0.13 0.217,0.138 0.254,0.138C0.295,0.138 0.326,0.13 0.349,0.113C0.371,0.097 0.386,0.074 0.394,0.045C0.398,0.027 0.4,-0.011 0.4,-0.068C0.361,-0.023 0.314,-0 0.256,-0C0.185,-0 0.13,-0.026 0.091,-0.077C0.052,-0.129 0.032,-0.19 0.032,-0.262C0.032,-0.312 0.041,-0.357 0.059,-0.399C0.077,-0.441 0.103,-0.473 0.137,-0.496C0.171,-0.519 0.211,-0.53 0.257,-0.53C0.318,-0.53 0.368,-0.506 0.408,-0.456L0.408,-0.519L0.489,-0.519L0.489,-0.07C0.489,0.01 0.481,0.068 0.464,0.101C0.448,0.135 0.422,0.162 0.386,0.181C0.351,0.201 0.307,0.21 0.255,0.21C0.193,0.21 0.143,0.196 0.105,0.168C0.067,0.141 0.049,0.099 0.05,0.043ZM0.123,-0.269C0.123,-0.201 0.136,-0.151 0.163,-0.12C0.19,-0.088 0.224,-0.073 0.265,-0.073C0.305,-0.073 0.339,-0.088 0.366,-0.119C0.394,-0.15 0.407,-0.199 0.407,-0.266C0.407,-0.329 0.393,-0.377 0.365,-0.409C0.337,-0.441 0.303,-0.458 0.263,-0.458C0.224,-0.458 0.191,-0.442 0.164,-0.41C0.136,-0.378 0.123,-0.331 0.123,-0.269Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.5 KiB |
BIN
assets/logo.png
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -1 +1,17 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 450 450" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1,0,0,1,-70.3466,-70.3466)">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1257.39,-1386.74)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.793058,0,0,0.793058,-739.034,-836.215)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 688 B After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -11,7 +11,15 @@ 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 Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
Tag,
|
||||
UserProfile,
|
||||
Toast,
|
||||
FeedToken,
|
||||
)
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkBundle(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"owner",
|
||||
"order",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"date_created",
|
||||
)
|
||||
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
list_filter = ("owner__username",)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
|
34
bookmarks/api/auth.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
||||
|
||||
|
||||
class LinkdingTokenAuthentication(TokenAuthentication):
|
||||
"""
|
||||
Extends DRF TokenAuthentication to add support for multiple keywords
|
||||
"""
|
||||
|
||||
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
|
||||
|
||||
def authenticate(self, request):
|
||||
auth = get_authorization_header(request).split()
|
||||
|
||||
if not auth or auth[0].lower() not in self.keywords:
|
||||
return None
|
||||
|
||||
if len(auth) == 1:
|
||||
msg = _("Invalid token header. No credentials provided.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
elif len(auth) > 2:
|
||||
msg = _("Invalid token header. Token string should not contain spaces.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
token = auth[1].decode()
|
||||
except UnicodeError:
|
||||
msg = _(
|
||||
"Invalid token header. Token string should not contain invalid characters."
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
return self.authenticate_credentials(token)
|
@@ -1,25 +1,34 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import SimpleRouter, DefaultRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import (
|
||||
BookmarkSerializer,
|
||||
BookmarkAssetSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
BookmarkBundleSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
website_loader,
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
User,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +41,7 @@ class BookmarkViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
@@ -46,71 +56,63 @@ class BookmarkViewSet(
|
||||
return super().get_permissions()
|
||||
|
||||
def get_queryset(self):
|
||||
# Provide filtered queryset for list actions
|
||||
user = self.request.user
|
||||
# For list action, use query set that applies search and tag projections
|
||||
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||
if self.action == "list":
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
elif self.action == "archived":
|
||||
return queries.query_archived_bookmarks(user, user.profile, search)
|
||||
elif self.action == "shared":
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmarks(
|
||||
user, self.request.user_profile, search, public_only
|
||||
)
|
||||
|
||||
# For single entity actions use default query set without projections
|
||||
# For single entity actions return user owned bookmarks
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"request": self.request, "user": self.request.user}
|
||||
disable_scraping = "disable_scraping" in self.request.GET
|
||||
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
|
||||
return {
|
||||
"request": self.request,
|
||||
"user": self.request.user,
|
||||
"disable_scraping": disable_scraping,
|
||||
"disable_html_snapshot": disable_html_snapshot,
|
||||
}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
def archived(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def shared(self, request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not request.user.is_authenticated
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
def shared(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def archive(self, request, pk):
|
||||
def archive(self, request: HttpRequest, pk):
|
||||
bookmark = self.get_object()
|
||||
archive_bookmark(bookmark)
|
||||
bookmarks.archive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def unarchive(self, request, pk):
|
||||
def unarchive(self, request: HttpRequest, pk):
|
||||
bookmark = self.get_object()
|
||||
unarchive_bookmark(bookmark)
|
||||
bookmarks.unarchive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def check(self, request):
|
||||
def check(self, request: HttpRequest):
|
||||
url = request.GET.get("url")
|
||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
@@ -120,7 +122,7 @@ class BookmarkViewSet(
|
||||
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||
f"Failed to auto-tag bookmark. url={url}",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
@@ -133,6 +135,116 @@ class BookmarkViewSet(
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def singlefile(self, request: HttpRequest):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
url = request.POST.get("url")
|
||||
file = request.FILES.get("file")
|
||||
|
||||
if not url or not file:
|
||||
return Response(
|
||||
{"error": "Both 'url' and 'file' parameters are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(url=url)
|
||||
bookmark = bookmarks.create_bookmark(
|
||||
bookmark, "", request.user, disable_html_snapshot=True
|
||||
)
|
||||
bookmarks.enhance_with_website_metadata(bookmark)
|
||||
|
||||
assets.upload_snapshot(bookmark, file.read())
|
||||
|
||||
return Response(
|
||||
{"message": "Snapshot uploaded successfully."},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkAssetViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkAssetSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# limit access to assets to the owner of the bookmark for now
|
||||
bookmark = access.bookmark_write(self.request, self.kwargs["bookmark_id"])
|
||||
return BookmarkAsset.objects.filter(
|
||||
bookmark_id=bookmark.id, bookmark__owner=user
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="download")
|
||||
def download(self, request: HttpRequest, bookmark_id, pk):
|
||||
asset = self.get_object()
|
||||
try:
|
||||
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
content_type = asset.content_type
|
||||
file_stream = (
|
||||
gzip.GzipFile(file_path, mode="rb")
|
||||
if asset.gzip
|
||||
else open(file_path, "rb")
|
||||
)
|
||||
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
||||
response["Content-Disposition"] = (
|
||||
f'attachment; filename="{asset.download_name}"'
|
||||
)
|
||||
return response
|
||||
except FileNotFoundError:
|
||||
raise Http404("Asset file does not exist")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def upload(self, request: HttpRequest, bookmark_id):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
bookmark = access.bookmark_write(request, bookmark_id)
|
||||
|
||||
upload_file = request.FILES.get("file")
|
||||
if not upload_file:
|
||||
return Response(
|
||||
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
serializer = self.get_serializer(asset)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Failed to upload asset."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
assets.remove_asset(instance)
|
||||
|
||||
|
||||
class TagViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
@@ -140,6 +252,7 @@ class TagViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = TagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -152,11 +265,48 @@ class TagViewSet(
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
@action(methods=["get"], detail=False)
|
||||
def profile(self, request):
|
||||
def profile(self, request: HttpRequest):
|
||||
return Response(UserProfileSerializer(request.user.profile).data)
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||
router.register(r"tags", TagViewSet, basename="tag")
|
||||
router.register(r"user", UserViewSet, basename="user")
|
||||
class BookmarkBundleViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkBundleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return BookmarkBundle.objects.filter(owner=user).order_by("order")
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
bundles.delete_bundle(instance)
|
||||
|
||||
|
||||
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
||||
# Instead create separate routers for each view set and manually register them in urls.py
|
||||
# The default router is only used to allow reversing a URL for the API root
|
||||
default_router = DefaultRouter()
|
||||
|
||||
bookmark_router = SimpleRouter()
|
||||
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
|
||||
|
||||
tag_router = SimpleRouter()
|
||||
tag_router.register("", TagViewSet, basename="tag")
|
||||
|
||||
user_router = SimpleRouter()
|
||||
user_router.register("", UserViewSet, basename="user")
|
||||
|
||||
bundle_router = SimpleRouter()
|
||||
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
|
||||
|
||||
bookmark_asset_router = SimpleRouter()
|
||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||
|
@@ -1,11 +1,20 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.db.models import Max, prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
UserProfile,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services import bookmarks, bundles
|
||||
from bookmarks.services.tags import get_or_create_tag
|
||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||
from bookmarks.utils import app_version
|
||||
|
||||
|
||||
class TagListField(serializers.ListField):
|
||||
@@ -20,6 +29,37 @@ class BookmarkListSerializer(ListSerializer):
|
||||
return super().to_representation(data)
|
||||
|
||||
|
||||
class EmtpyField(serializers.ReadOnlyField):
|
||||
def to_representation(self, value):
|
||||
return None
|
||||
|
||||
|
||||
class BookmarkBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"order",
|
||||
"date_created",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"date_created",
|
||||
"date_modified",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
bundle = BookmarkBundle(**validated_data)
|
||||
bundle.order = validated_data["order"] if "order" in validated_data else None
|
||||
return bundles.create_bundle(bundle, self.context["user"])
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
@@ -29,8 +69,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
@@ -40,29 +78,30 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
read_only_fields = [
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
list_serializer_class = BookmarkListSerializer
|
||||
|
||||
# Override optional char fields to provide default value
|
||||
title = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
is_archived = serializers.BooleanField(required=False, default=False)
|
||||
unread = serializers.BooleanField(required=False, default=False)
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
# Custom tag_names field to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False)
|
||||
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
|
||||
favicon_url = serializers.SerializerMethodField()
|
||||
preview_image_url = serializers.SerializerMethodField()
|
||||
web_archive_snapshot_url = serializers.SerializerMethodField()
|
||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||
website_title = EmtpyField()
|
||||
website_description = EmtpyField()
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
@@ -80,30 +119,75 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def get_web_archive_snapshot_url(self, obj: Bookmark):
|
||||
if obj.web_archive_snapshot_url:
|
||||
return obj.web_archive_snapshot_url
|
||||
|
||||
return generate_fallback_webarchive_url(obj.url, obj.date_added)
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
bookmark.url = validated_data["url"]
|
||||
bookmark.title = validated_data["title"]
|
||||
bookmark.description = validated_data["description"]
|
||||
bookmark.notes = validated_data["notes"]
|
||||
bookmark.is_archived = validated_data["is_archived"]
|
||||
bookmark.unread = validated_data["unread"]
|
||||
bookmark.shared = validated_data["shared"]
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
return create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
tag_names = validated_data.pop("tag_names", [])
|
||||
tag_string = build_tag_string(tag_names)
|
||||
bookmark = Bookmark(**validated_data)
|
||||
|
||||
disable_scraping = self.context.get("disable_scraping", False)
|
||||
disable_html_snapshot = self.context.get("disable_html_snapshot", False)
|
||||
|
||||
saved_bookmark = bookmarks.create_bookmark(
|
||||
bookmark,
|
||||
tag_string,
|
||||
self.context["user"],
|
||||
disable_html_snapshot=disable_html_snapshot,
|
||||
)
|
||||
# Unless scraping is explicitly disabled, enhance bookmark with website
|
||||
# metadata to preserve backwards compatibility with clients that expect
|
||||
# title and description to be populated automatically when left empty
|
||||
if not disable_scraping:
|
||||
bookmarks.enhance_with_website_metadata(saved_bookmark)
|
||||
return saved_bookmark
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
# Update fields if they were provided in the payload
|
||||
for key in ["url", "title", "description", "notes", "unread", "shared"]:
|
||||
if key in validated_data:
|
||||
setattr(instance, key, validated_data[key])
|
||||
tag_names = validated_data.pop("tag_names", instance.tag_names)
|
||||
tag_string = build_tag_string(tag_names)
|
||||
|
||||
# Use tag string from payload, or use bookmark's current tags as fallback
|
||||
tag_string = build_tag_string(instance.tag_names)
|
||||
if "tag_names" in validated_data:
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
for field_name, field in self.fields.items():
|
||||
if not field.read_only and field_name in validated_data:
|
||||
setattr(instance, field_name, validated_data[field_name])
|
||||
|
||||
return update_bookmark(instance, tag_string, self.context["user"])
|
||||
return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
|
||||
|
||||
def validate(self, attrs):
|
||||
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||
# updating the existing bookmark instead. When editing a bookmark,
|
||||
# there is no assumption that it would update a different bookmark if
|
||||
# the URL is a duplicate, so raise a validation error in that case.
|
||||
if self.instance and "url" in attrs:
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
if is_duplicate:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "A bookmark with this URL already exists."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookmarkAssetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookmarkAsset
|
||||
fields = [
|
||||
"id",
|
||||
"bookmark",
|
||||
"date_created",
|
||||
"file_size",
|
||||
"asset_type",
|
||||
"content_type",
|
||||
"display_name",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
@@ -131,4 +215,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"search_preferences",
|
||||
"version",
|
||||
]
|
||||
|
||||
version = serializers.ReadOnlyField(default=app_version)
|
||||
|
@@ -1,6 +1,5 @@
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import BookmarkSearch, Toast
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import Toast
|
||||
|
||||
|
||||
def toasts(request):
|
||||
|
@@ -1,109 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(
|
||||
title="Existing title",
|
||||
description="Existing description",
|
||||
notes="Existing notes",
|
||||
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
|
||||
website_title="Existing website title",
|
||||
website_description="Existing website description",
|
||||
unread=True,
|
||||
)
|
||||
tag_names = " ".join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label("URL").fill(existing_bookmark.url)
|
||||
# Already bookmarked hint should be visible
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(
|
||||
existing_bookmark.title, page.get_by_label("Title").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.description,
|
||||
page.get_by_label("Description").input_value(),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.notes, page.get_by_label("Notes").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.website_title,
|
||||
page.get_by_label("Title").get_attribute("placeholder"),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.website_description,
|
||||
page.get_by_label("Description").get_attribute("placeholder"),
|
||||
)
|
||||
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
|
||||
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
|
||||
|
||||
# Enter non-bookmarked URL
|
||||
page.get_by_label("URL").fill("https://example.com/unknown")
|
||||
# Already bookmarked hint should be hidden
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||
state="hidden", timeout=2000
|
||||
)
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_edit_should_not_check_for_existing_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(
|
||||
self.live_server_url + reverse("bookmarks:edit", args=[bookmark.id])
|
||||
)
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
notes="Existing notes", description="Existing description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
details = page.locator("details.notes")
|
||||
expect(details).not_to_have_attribute("open", value="")
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
url = self.live_server_url + reverse("bookmarks:new")
|
||||
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
page.goto(url)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
from bookmarks.views import access
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,10 +31,16 @@ def sanitize(text: str):
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
bundle = None
|
||||
bundle_id = request.GET.get("bundle")
|
||||
if bundle_id:
|
||||
bundle = access.bundle_read(request, bundle_id)
|
||||
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
bundle=bundle,
|
||||
)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
@@ -74,7 +81,7 @@ class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.all", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -87,7 +94,7 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
).filter(unread=True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.unread", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -100,7 +107,7 @@ class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -114,4 +121,4 @@ class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.public_shared")
|
||||
return reverse("linkding:feeds.public_shared")
|
||||
|
102
bookmarks/forms.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from django import forms
|
||||
from django.forms.utils import ErrorList
|
||||
|
||||
from bookmarks.models import Bookmark, build_tag_string
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
|
||||
|
||||
class CustomErrorList(ErrorList):
|
||||
template_name = "shared/error_list.html"
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
def __init__(self, request: HttpRequest, instance: Bookmark = None):
|
||||
self.request = request
|
||||
|
||||
initial = None
|
||||
if instance is None and request.method == "GET":
|
||||
initial = {
|
||||
"url": request.GET.get("url"),
|
||||
"title": request.GET.get("title"),
|
||||
"description": request.GET.get("description"),
|
||||
"notes": request.GET.get("notes"),
|
||||
"tag_string": request.GET.get("tags"),
|
||||
"auto_close": "auto_close" in request.GET,
|
||||
"unread": request.user_profile.default_mark_unread,
|
||||
}
|
||||
if instance is not None and request.method == "GET":
|
||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||
data = request.POST if request.method == "POST" else None
|
||||
super().__init__(
|
||||
data, instance=instance, initial=initial, error_class=CustomErrorList
|
||||
)
|
||||
|
||||
@property
|
||||
def is_auto_close(self):
|
||||
return self.data.get("auto_close", False) == "True" or self.initial.get(
|
||||
"auto_close", False
|
||||
)
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
|
||||
def save(self, commit=False):
|
||||
tag_string = convert_tag_string(self.data["tag_string"])
|
||||
bookmark = super().save(commit=False)
|
||||
if self.instance.pk:
|
||||
return update_bookmark(bookmark, tag_string, self.request.user)
|
||||
else:
|
||||
return create_bookmark(bookmark, tag_string, self.request.user)
|
||||
|
||||
def clean_url(self):
|
||||
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||
# updating the existing bookmark instead, which is also communicated in
|
||||
# the form's UI. When editing a bookmark, there is no assumption that
|
||||
# it would update a different bookmark if the URL is a duplicate, so
|
||||
# raise a validation error in that case.
|
||||
url = self.cleaned_data["url"]
|
||||
if self.instance.pk:
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=url)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
if is_duplicate:
|
||||
raise forms.ValidationError("A bookmark with this URL already exists.")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
return tag_string.replace(" ", ",")
|
42
bookmarks/frontend/behaviors/clear-button.js
Normal file
@@ -0,0 +1,42 @@
|
||||
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,60 +1,22 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
|
||||
import { ModalBehavior } from "./modal";
|
||||
|
||||
class DetailsModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
class DetailsModalBehavior extends ModalBehavior {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.element.dataset.closeUrl;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
|
||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||
|
||||
this.overlayLink.addEventListener("click", this.onClose);
|
||||
this.buttonLink.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlayLink.removeEventListener("click", this.onClose);
|
||||
this.buttonLink.removeEventListener("click", this.onClose);
|
||||
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;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
|
||||
const closeUrl = this.overlayLink.href;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
// 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click then JS is enabled
|
||||
this.element.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.element.addEventListener("keydown", this.onEscape);
|
||||
this.element.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
this.element.removeEventListener("keydown", this.onEscape);
|
||||
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.element.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.element.classList.remove("active");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.element.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||
|
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
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="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);
|
124
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,124 @@
|
||||
let keyboardActive = false;
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
() => {
|
||||
keyboardActive = true;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"mousedown",
|
||||
() => {
|
||||
keyboardActive = false;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
export function isKeyboardActive() {
|
||||
return keyboardActive;
|
||||
}
|
||||
|
||||
export class FocusTrapController {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.focusableElements = this.element.querySelectorAll(
|
||||
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
this.firstFocusableElement = this.focusableElements[0];
|
||||
this.lastFocusableElement =
|
||||
this.focusableElements[this.focusableElements.length - 1];
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === this.firstFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.lastFocusableElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === this.lastFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let afterPageLoadFocusTarget = [];
|
||||
let firstPageLoad = true;
|
||||
|
||||
export function setAfterPageLoadFocusTarget(...targets) {
|
||||
afterPageLoadFocusTarget = targets;
|
||||
}
|
||||
|
||||
function programmaticFocus(element) {
|
||||
// Ensure element is focusable
|
||||
// Hide focus outline if element is not focusable by default - might
|
||||
// reconsider this later
|
||||
const isFocusable = element.tabIndex >= 0;
|
||||
if (!isFocusable) {
|
||||
// Apparently the default tabIndex is -1, even though an element is still
|
||||
// not focusable with that. Setting an explicit -1 also sets the attribute
|
||||
// and the element becomes focusable.
|
||||
element.tabIndex = -1;
|
||||
// `focusVisible` is not supported in all browsers, so hide the outline manually
|
||||
element.style["outline"] = "none";
|
||||
}
|
||||
element.focus({
|
||||
focusVisible: isKeyboardActive() && isFocusable,
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Register global listener for navigation and try to focus an element that
|
||||
// results in a meaningful announcement.
|
||||
document.addEventListener("turbo:load", () => {
|
||||
// Ignore initial page load to let the browser handle announcements
|
||||
if (firstPageLoad) {
|
||||
firstPageLoad = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is an explicit focus target for the next page load
|
||||
for (const target of afterPageLoadFocusTarget) {
|
||||
const element = document.querySelector(target);
|
||||
if (element) {
|
||||
programmaticFocus(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
afterPageLoadFocusTarget = [];
|
||||
|
||||
// If there is some autofocus element, let the browser handle it
|
||||
const autofocus = document.querySelector("[autofocus]");
|
||||
if (autofocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is a toast as a result of some action, focus it
|
||||
const toast = document.querySelector(".toast");
|
||||
if (toast) {
|
||||
programmaticFocus(toast);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise go with main
|
||||
const main = document.querySelector("main");
|
||||
if (main) {
|
||||
programmaticFocus(main);
|
||||
}
|
||||
});
|
@@ -16,7 +16,24 @@ const mutationObserver = new MutationObserver((mutations) => {
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:load", () => {
|
||||
// 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,
|
||||
@@ -41,7 +58,6 @@ Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
applyBehaviors(document, [name]);
|
||||
}
|
||||
|
||||
export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
91
bookmarks/frontend/behaviors/modal.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Behavior } from "./index";
|
||||
import { FocusTrapController } from "./focus-utils";
|
||||
|
||||
export class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.overlay = element.querySelector(".modal-overlay");
|
||||
this.closeButton = element.querySelector(".modal-header .close");
|
||||
|
||||
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.clearInert();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupInert() {
|
||||
// Inert all other elements on the page
|
||||
document
|
||||
.querySelectorAll("body > *:not(.modals)")
|
||||
.forEach((el) => el.setAttribute("inert", ""));
|
||||
// Lock scroll on the body
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.clearInert();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
@@ -1,68 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class TagModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>Tags</h2>
|
||||
<button class="close" aria-label="Close">
|
||||
<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>
|
||||
`;
|
||||
|
||||
const tagCloud = document.querySelector(".tag-cloud");
|
||||
const tagCloudContainer = tagCloud.parentElement;
|
||||
|
||||
const content = modal.querySelector(".content");
|
||||
content.appendChild(tagCloud);
|
||||
|
||||
const overlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".close");
|
||||
overlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
this.modal = modal;
|
||||
this.tagCloud = tagCloud;
|
||||
this.tagCloudContainer = tagCloudContainer;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.remove();
|
||||
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-modal", TagModalBehavior);
|
@@ -122,7 +122,7 @@
|
||||
}
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||
const fullLabel = bookmark.title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
return {
|
||||
type: 'bookmark',
|
||||
|
@@ -6,6 +6,7 @@
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let ariaDescribedBy;
|
||||
export let variant = 'default';
|
||||
|
||||
let isFocus = false;
|
||||
@@ -77,6 +78,7 @@
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
|
||||
|
||||
close();
|
||||
}
|
||||
@@ -109,6 +111,7 @@
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
aria-describedby="{ariaDescribedBy}"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
@@ -128,41 +131,41 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,14 +1,15 @@
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
|
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.8 on 2024-09-18 20:11
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="items_per_page",
|
||||
field=models.IntegerField(
|
||||
default=30, validators=[django.core.validators.MinValueValidator(10)]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="sticky_pagination",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
36
bookmarks/migrations/0041_merge_metadata.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-21 08:13
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Bookmark.objects.filter(
|
||||
Q(title__isnull=True) | Q(title__exact=""),
|
||||
).extra(
|
||||
where=["website_title IS NOT NULL"]
|
||||
).update(title=RawSQL("website_title", ()))
|
||||
|
||||
Bookmark.objects.filter(
|
||||
Q(description__isnull=True) | Q(description__exact=""),
|
||||
).extra(where=["website_description IS NOT NULL"]).update(
|
||||
description=RawSQL("website_description", ())
|
||||
)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0040_userprofile_items_per_page_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
18
bookmarks/migrations/0042_userprofile_custom_css_hash.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-28 08:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0041_merge_metadata"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="custom_css_hash",
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-02 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0042_userprofile_custom_css_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="collapse_side_panel",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
46
bookmarks/migrations/0044_bookmark_latest_snapshot.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 12:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
# Update the latest snapshot for each bookmark
|
||||
Bookmark = apps.get_model("bookmarks", "bookmark")
|
||||
BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset")
|
||||
|
||||
latest_snapshots = (
|
||||
BookmarkAsset.objects.filter(
|
||||
bookmark=OuterRef("pk"), asset_type="snapshot", status="complete"
|
||||
)
|
||||
.order_by("-date_created")
|
||||
.values("id")[:1]
|
||||
)
|
||||
Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0043_userprofile_collapse_side_panel"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="latest_snapshot",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="latest_snapshot",
|
||||
to="bookmarks.bookmarkasset",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.1.9 on 2025-06-19 08:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0044_bookmark_latest_snapshot"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="hide_bundles",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookmarkBundle",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=256)),
|
||||
("search", models.CharField(blank=True, max_length=256)),
|
||||
("any_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("all_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("excluded_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("order", models.IntegerField(default=0)),
|
||||
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||
("date_modified", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@@ -1,12 +1,14 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property
|
||||
from typing import List
|
||||
|
||||
import binascii
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -21,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
date_added = models.DateTimeField()
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -38,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
# remove empty names, sanitize remaining names
|
||||
names = [sanitize_tag_name(name) for name in names if name]
|
||||
names = [sanitize_tag_name(name) for name in names if name.strip()]
|
||||
# remove duplicates
|
||||
names = unique(names, str.lower)
|
||||
names.sort(key=str.lower)
|
||||
@@ -55,7 +57,9 @@ class Bookmark(models.Model):
|
||||
title = models.CharField(max_length=512, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
# Obsolete field, kept to not remove column when generating migrations
|
||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||
# Obsolete field, kept to not remove column when generating migrations
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||
favicon_file = models.CharField(max_length=512, blank=True)
|
||||
@@ -66,21 +70,26 @@ class Bookmark(models.Model):
|
||||
date_added = models.DateTimeField()
|
||||
date_modified = models.DateTimeField()
|
||||
date_accessed = models.DateTimeField(blank=True, null=True)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
tags = models.ManyToManyField(Tag)
|
||||
latest_snapshot = models.ForeignKey(
|
||||
"BookmarkAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="latest_snapshot",
|
||||
)
|
||||
|
||||
@property
|
||||
def resolved_title(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
elif self.website_title:
|
||||
return self.website_title
|
||||
else:
|
||||
return self.url
|
||||
|
||||
@property
|
||||
def resolved_description(self):
|
||||
return self.website_description if not self.description else self.description
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def tag_names(self):
|
||||
@@ -91,6 +100,19 @@ class Bookmark(models.Model):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Bookmark)
|
||||
def bookmark_deleted(sender, instance, **kwargs):
|
||||
if instance.preview_image_file:
|
||||
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
|
||||
if os.path.isfile(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Failed to delete preview image: {filepath}", exc_info=error
|
||||
)
|
||||
|
||||
|
||||
class BookmarkAsset(models.Model):
|
||||
TYPE_SNAPSHOT = "snapshot"
|
||||
TYPE_UPLOAD = "upload"
|
||||
@@ -111,6 +133,14 @@ class BookmarkAsset(models.Model):
|
||||
status = models.CharField(max_length=64, blank=False, null=False)
|
||||
gzip = models.BooleanField(default=False, null=False)
|
||||
|
||||
@property
|
||||
def download_name(self):
|
||||
return (
|
||||
f"{self.display_name}.html"
|
||||
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
||||
else self.display_name
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.file:
|
||||
try:
|
||||
@@ -136,43 +166,25 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description in form as we fill these automatically if they are empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
# Include website title and description as hidden field as they only provide info when editing bookmarks
|
||||
website_title = forms.CharField(
|
||||
max_length=512, required=False, widget=forms.HiddenInput()
|
||||
)
|
||||
website_description = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
class BookmarkBundle(models.Model):
|
||||
name = models.CharField(max_length=256, blank=False)
|
||||
search = models.CharField(max_length=256, blank=True)
|
||||
any_tags = models.CharField(max_length=1024, blank=True)
|
||||
all_tags = models.CharField(max_length=1024, blank=True)
|
||||
excluded_tags = models.CharField(max_length=1024, blank=True)
|
||||
order = models.IntegerField(null=False, default=0)
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||
date_modified = models.DateTimeField(auto_now=True, null=False)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BookmarkBundleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
model = BookmarkBundle
|
||||
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
@@ -189,34 +201,54 @@ class BookmarkSearch:
|
||||
FILTER_UNREAD_YES = "yes"
|
||||
FILTER_UNREAD_NO = "no"
|
||||
|
||||
params = ["q", "user", "sort", "shared", "unread"]
|
||||
params = [
|
||||
"q",
|
||||
"user",
|
||||
"bundle",
|
||||
"sort",
|
||||
"shared",
|
||||
"unread",
|
||||
"modified_since",
|
||||
"added_since",
|
||||
]
|
||||
preferences = ["sort", "shared", "unread"]
|
||||
defaults = {
|
||||
"q": "",
|
||||
"user": "",
|
||||
"bundle": None,
|
||||
"sort": SORT_ADDED_DESC,
|
||||
"shared": FILTER_SHARED_OFF,
|
||||
"unread": FILTER_UNREAD_OFF,
|
||||
"modified_since": None,
|
||||
"added_since": None,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
bundle: BookmarkBundle = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
modified_since: str = None,
|
||||
added_since: str = None,
|
||||
preferences: dict = None,
|
||||
request: any = None,
|
||||
):
|
||||
if not preferences:
|
||||
preferences = {}
|
||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||
self.request = request
|
||||
|
||||
self.q = q or self.defaults["q"]
|
||||
self.user = user or self.defaults["user"]
|
||||
self.bundle = bundle or self.defaults["bundle"]
|
||||
self.sort = sort or self.defaults["sort"]
|
||||
self.shared = shared or self.defaults["shared"]
|
||||
self.unread = unread or self.defaults["unread"]
|
||||
self.modified_since = modified_since or self.defaults["modified_since"]
|
||||
self.added_since = added_since or self.defaults["added_since"]
|
||||
|
||||
def is_modified(self, param):
|
||||
value = self.__dict__[param]
|
||||
@@ -244,7 +276,14 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
def query_params(self):
|
||||
return {param: self.__dict__[param] for param in self.modified_params}
|
||||
query_params = {}
|
||||
for param in self.modified_params:
|
||||
value = self.__dict__[param]
|
||||
if isinstance(value, models.Model):
|
||||
query_params[param] = value.id
|
||||
else:
|
||||
query_params[param] = value
|
||||
return query_params
|
||||
|
||||
@property
|
||||
def preferences_dict(self):
|
||||
@@ -253,14 +292,21 @@ class BookmarkSearch:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||
initial_values = {}
|
||||
for param in BookmarkSearch.params:
|
||||
value = query_dict.get(param)
|
||||
if value:
|
||||
initial_values[param] = value
|
||||
if param == "bundle":
|
||||
initial_values[param] = BookmarkBundle.objects.filter(
|
||||
owner=request.user, pk=value
|
||||
).first()
|
||||
else:
|
||||
initial_values[param] = value
|
||||
|
||||
return BookmarkSearch(**initial_values, preferences=preferences)
|
||||
return BookmarkSearch(
|
||||
**initial_values, preferences=preferences, request=request
|
||||
)
|
||||
|
||||
|
||||
class BookmarkSearchForm(forms.Form):
|
||||
@@ -283,9 +329,12 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
bundle = forms.CharField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
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,
|
||||
@@ -305,7 +354,11 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
for param in search.params:
|
||||
# set initial values for modified params
|
||||
self.fields[param].initial = search.__dict__[param]
|
||||
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
|
||||
@@ -361,9 +414,7 @@ class UserProfile(models.Model):
|
||||
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||
]
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
|
||||
theme = models.CharField(
|
||||
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
|
||||
)
|
||||
@@ -418,10 +469,26 @@ class UserProfile(models.Model):
|
||||
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
permanent_notes = models.BooleanField(default=False, null=False)
|
||||
custom_css = models.TextField(blank=True, null=False)
|
||||
custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
|
||||
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||
items_per_page = models.IntegerField(
|
||||
null=False, default=30, validators=[MinValueValidator(10)]
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
hide_bundles = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
self.custom_css_hash = hashlib.md5(
|
||||
self.custom_css.encode("utf-8")
|
||||
).hexdigest()
|
||||
else:
|
||||
self.custom_css_hash = ""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -450,16 +517,20 @@ class UserProfileForm(forms.ModelForm):
|
||||
"default_mark_unread",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
"hide_bundles",
|
||||
]
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
||||
|
||||
@@ -468,7 +539,7 @@ class Toast(models.Model):
|
||||
key = models.CharField(max_length=50)
|
||||
message = models.TextField()
|
||||
acknowledged = models.BooleanField(default=False)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class FeedToken(models.Model):
|
||||
@@ -478,7 +549,7 @@ class FeedToken(models.Model):
|
||||
|
||||
key = models.CharField(max_length=40, primary_key=True)
|
||||
user = models.OneToOneField(
|
||||
get_user_model(),
|
||||
User,
|
||||
related_name="feed_token",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
@@ -512,7 +583,7 @@ class GlobalSettings(models.Model):
|
||||
default=LANDING_PAGE_LOGIN,
|
||||
)
|
||||
guest_profile_user = models.ForeignKey(
|
||||
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
enable_link_prefetch = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
@@ -2,16 +2,26 @@ from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
UserProfile,
|
||||
parse_tag_string,
|
||||
)
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
user: User,
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||
|
||||
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
|
||||
# Search terms
|
||||
search_terms = parse_query_string(bundle.search)["search_terms"]
|
||||
for term in search_terms:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
# Any tags - at least one tag must match
|
||||
any_tags = parse_tag_string(bundle.any_tags, " ")
|
||||
if len(any_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in any_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
|
||||
query_set = query_set.filter(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
# All tags - all tags must match
|
||||
all_tags = parse_tag_string(bundle.all_tags, " ")
|
||||
for tag in all_tags:
|
||||
query_set = query_set.filter(tags__name__iexact=tag)
|
||||
|
||||
# Excluded tags - no tags must match
|
||||
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
|
||||
if len(exclude_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in exclude_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
query_set = query_set.exclude(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
return query_set
|
||||
|
||||
|
||||
def _base_bookmarks_query(
|
||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
|
||||
if user:
|
||||
query_set = query_set.filter(owner=user)
|
||||
|
||||
# Filter by modified_since if provided
|
||||
if search.modified_since:
|
||||
try:
|
||||
query_set = query_set.filter(date_modified__gt=search.modified_since)
|
||||
except ValidationError:
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# Filter by added_since if provided
|
||||
if search.added_since:
|
||||
try:
|
||||
query_set = query_set.filter(date_added__gt=search.added_since)
|
||||
except ValidationError:
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# Split query into search terms and tags
|
||||
query = parse_query_string(search.q)
|
||||
|
||||
@@ -53,8 +122,6 @@ def _base_bookmarks_query(
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(website_title__icontains=term)
|
||||
| Q(website_description__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
|
||||
@@ -87,13 +154,11 @@ def _base_bookmarks_query(
|
||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||
query_set = query_set.filter(shared=False)
|
||||
|
||||
# Sort by date added
|
||||
if search.sort == BookmarkSearch.SORT_ADDED_ASC:
|
||||
query_set = query_set.order_by("date_added")
|
||||
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
|
||||
query_set = query_set.order_by("-date_added")
|
||||
# Filter by bundle
|
||||
if search.bundle:
|
||||
query_set = _filter_bundle(query_set, search.bundle)
|
||||
|
||||
# Sort by title
|
||||
# Sort
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
or search.sort == BookmarkSearch.SORT_TITLE_DESC
|
||||
@@ -103,10 +168,6 @@ def _base_bookmarks_query(
|
||||
query_set = query_set.annotate(
|
||||
effective_title=Case(
|
||||
When(Q(title__isnull=False) & ~Q(title__exact=""), then=Lower("title")),
|
||||
When(
|
||||
Q(website_title__isnull=False) & ~Q(website_title__exact=""),
|
||||
then=Lower("website_title"),
|
||||
),
|
||||
default=Lower("url"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
@@ -124,6 +185,11 @@ def _base_bookmarks_query(
|
||||
query_set = query_set.order_by(order_field)
|
||||
elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
|
||||
query_set = query_set.order_by(order_field).reverse()
|
||||
elif search.sort == BookmarkSearch.SORT_ADDED_ASC:
|
||||
query_set = query_set.order_by("date_added")
|
||||
else:
|
||||
# Sort by date added, descending by default
|
||||
query_set = query_set.order_by("-date_added")
|
||||
|
||||
return query_set
|
||||
|
||||
|
177
bookmarks/services/assets.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset
|
||||
from bookmarks.services import singlefile
|
||||
|
||||
MAX_ASSET_FILENAME_LENGTH = 192
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
date_created = timezone.now()
|
||||
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
date_created=date_created,
|
||||
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
|
||||
display_name=f"HTML snapshot from {timestamp}",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def create_snapshot(asset: BookmarkAsset):
|
||||
try:
|
||||
# Create snapshot into temporary file
|
||||
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
|
||||
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
|
||||
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
|
||||
|
||||
# Store as gzip in asset folder
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(temp_filepath, "rb") as temp_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
shutil.copyfileobj(temp_file, gz_file)
|
||||
|
||||
# Remove temporary file
|
||||
os.remove(temp_filepath)
|
||||
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
except Exception as error:
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
raise error
|
||||
|
||||
|
||||
def upload_snapshot(bookmark: Bookmark, html: bytes):
|
||||
asset = create_snapshot_asset(bookmark)
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
|
||||
with gzip.open(filepath, "wb") as gz_file:
|
||||
gz_file.write(html)
|
||||
|
||||
# Only save the asset if the file was written successfully
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
||||
try:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
date_created=timezone.now(),
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
gzip=False,
|
||||
)
|
||||
name, extension = os.path.splitext(upload_file.name)
|
||||
|
||||
# automatically gzip the file if it is not already gzipped
|
||||
if upload_file.content_type != "application/gzip":
|
||||
filename = _generate_asset_filename(
|
||||
asset, name, extension.lstrip(".") + ".gz"
|
||||
)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with gzip.open(filepath, "wb", compresslevel=9) as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.gzip = True
|
||||
asset.file = filename
|
||||
asset.file_size = os.path.getsize(filepath)
|
||||
else:
|
||||
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
return asset
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
def remove_asset(asset: BookmarkAsset):
|
||||
# If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot
|
||||
bookmark = asset.bookmark
|
||||
if bookmark and bookmark.latest_snapshot == asset:
|
||||
latest = (
|
||||
BookmarkAsset.objects.filter(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
.exclude(pk=asset.pk)
|
||||
.order_by("-date_created")
|
||||
.first()
|
||||
)
|
||||
|
||||
bookmark.latest_snapshot = latest
|
||||
|
||||
asset.delete()
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def _generate_asset_filename(
|
||||
asset: BookmarkAsset, filename: str, extension: str
|
||||
) -> str:
|
||||
def sanitize_char(char):
|
||||
if char.isalnum() or char in ("-", "_", "."):
|
||||
return char
|
||||
else:
|
||||
return "_"
|
||||
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
sanitized_filename = "".join(sanitize_char(char) for char in filename)
|
||||
|
||||
# Calculate the length of fixed parts of the final filename
|
||||
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
|
||||
# Calculate the maximum length for the dynamic part of the filename
|
||||
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
|
||||
# Truncate the filename if necessary
|
||||
sanitized_filename = sanitized_filename[:max_filename_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"
|
@@ -7,11 +7,22 @@ def get_tags(script: str, url: str):
|
||||
parsed_url = urlparse(url.lower())
|
||||
result = set()
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
if "#" in line:
|
||||
i = line.index("#")
|
||||
line = line[:i]
|
||||
if not parsed_url.hostname:
|
||||
return result
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines or lines that start with a comment
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Remove trailing comment - only if # is preceded by whitespace
|
||||
comment_match = re.search(r"\s+#", line)
|
||||
if comment_match:
|
||||
line = line[: comment_match.start()]
|
||||
|
||||
# Ignore lines that don't contain a URL and a tag
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
@@ -33,6 +44,11 @@ def get_tags(script: str, url: str):
|
||||
):
|
||||
continue
|
||||
|
||||
if parsed_pattern.fragment and not _fragment_matches(
|
||||
parsed_pattern.fragment, parsed_url.fragment
|
||||
):
|
||||
continue
|
||||
|
||||
for tag in parts[1:]:
|
||||
result.add(tag)
|
||||
|
||||
@@ -62,3 +78,7 @@ def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:
|
||||
return actual_fragment.startswith(expected_fragment)
|
||||
|
@@ -1,22 +1,23 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||
from bookmarks.models import Bookmark, User, parse_tag_string
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
def create_bookmark(
|
||||
bookmark: Bookmark,
|
||||
tag_string: str,
|
||||
current_user: User,
|
||||
disable_html_snapshot: bool = False,
|
||||
):
|
||||
# If URL is already bookmarked, then update it
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||
owner=current_user, url=bookmark.url
|
||||
@@ -26,8 +27,6 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
_merge_bookmark_data(bookmark, existing_bookmark)
|
||||
return update_bookmark(existing_bookmark, tag_string, current_user)
|
||||
|
||||
# Update website info
|
||||
_update_website_metadata(bookmark)
|
||||
# Set currently logged in user as owner
|
||||
bookmark.owner = current_user
|
||||
# Set dates
|
||||
@@ -44,7 +43,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
if (
|
||||
current_user.profile.enable_automatic_html_snapshots
|
||||
and not disable_html_snapshot
|
||||
):
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
return bookmark
|
||||
@@ -67,13 +69,21 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||
# Only update website metadata if URL changed
|
||||
_update_website_metadata(bookmark)
|
||||
bookmark.save()
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
def enhance_with_website_metadata(bookmark: Bookmark):
|
||||
metadata = website_loader.load_website_metadata(bookmark.url)
|
||||
if not bookmark.title:
|
||||
bookmark.title = metadata.title or ""
|
||||
|
||||
if not bookmark.description:
|
||||
bookmark.description = metadata.description or ""
|
||||
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def archive_bookmark(bookmark: Bookmark):
|
||||
bookmark.is_archived = True
|
||||
bookmark.date_modified = timezone.now()
|
||||
@@ -187,44 +197,24 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
)
|
||||
|
||||
|
||||
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
gzip=False,
|
||||
def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
owned_bookmarks = Bookmark.objects.filter(
|
||||
owner=current_user, id__in=sanitized_bookmark_ids
|
||||
)
|
||||
asset.save()
|
||||
|
||||
try:
|
||||
filename = _generate_upload_asset_filename(asset, upload_file.name)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
for bookmark in owned_bookmarks:
|
||||
tasks.refresh_metadata(bookmark)
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
asset.save()
|
||||
|
||||
return asset
|
||||
def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
owned_bookmarks = Bookmark.objects.filter(
|
||||
owner=current_user, id__in=sanitized_bookmark_ids
|
||||
)
|
||||
|
||||
tasks.create_html_snapshots(owned_bookmarks)
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
@@ -235,12 +225,6 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.shared = from_bookmark.shared
|
||||
|
||||
|
||||
def _update_website_metadata(bookmark: Bookmark):
|
||||
metadata = website_loader.load_website_metadata(bookmark.url)
|
||||
bookmark.website_title = metadata.title
|
||||
bookmark.website_description = metadata.description
|
||||
|
||||
|
||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
|
||||
|
37
bookmarks/services/bundles.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.db.models import Max
|
||||
|
||||
from bookmarks.models import BookmarkBundle, User
|
||||
|
||||
|
||||
def create_bundle(bundle: BookmarkBundle, current_user: User):
|
||||
bundle.owner = current_user
|
||||
if bundle.order is None:
|
||||
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
|
||||
Max("order", default=-1)
|
||||
)
|
||||
bundle.order = max_order_result["order__max"] + 1
|
||||
bundle.save()
|
||||
return bundle
|
||||
|
||||
|
||||
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
|
||||
user_bundles = list(
|
||||
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
|
||||
)
|
||||
|
||||
if new_order != user_bundles.index(bundle_to_move):
|
||||
user_bundles.remove(bundle_to_move)
|
||||
user_bundles.insert(new_order, bundle_to_move)
|
||||
for bundle_index, bundle in enumerate(user_bundles):
|
||||
bundle.order = bundle_index
|
||||
|
||||
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
||||
|
||||
|
||||
def delete_bundle(bundle: BookmarkBundle):
|
||||
bundle.delete()
|
||||
|
||||
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
|
||||
for index, user_bundle in enumerate(user_bundles):
|
||||
user_bundle.order = index
|
||||
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
@@ -35,14 +35,15 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]"
|
||||
tag_names = bookmark.tag_names
|
||||
if bookmark.is_archived:
|
||||
tag_names.append("linkding:archived")
|
||||
tag_names.append("linkding:bookmarks.archived")
|
||||
tags = ",".join(tag_names)
|
||||
toread = "1" if bookmark.unread else "0"
|
||||
private = "0" if bookmark.shared else "1"
|
||||
added = int(bookmark.date_added.timestamp())
|
||||
modified = int(bookmark.date_modified.timestamp())
|
||||
|
||||
doc.append(
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" LAST_MODIFIED="{modified}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
)
|
||||
|
||||
if desc:
|
||||
|
@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
for tag_name in netscape_bookmark.tag_names:
|
||||
# Skip tag names that exceed the maximum allowed length
|
||||
if len(tag_name) > 64:
|
||||
logger.warning(
|
||||
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
|
||||
)
|
||||
continue
|
||||
|
||||
tag = tag_cache.get(tag_name)
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name, owner=user)
|
||||
@@ -231,7 +238,10 @@ def _copy_bookmark_data(
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
else:
|
||||
bookmark.date_added = timezone.now()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
if netscape_bookmark.date_modified:
|
||||
bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)
|
||||
else:
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
bookmark.unread = netscape_bookmark.to_read
|
||||
if netscape_bookmark.title:
|
||||
bookmark.title = netscape_bookmark.title
|
||||
|
@@ -12,6 +12,7 @@ class NetscapeBookmark:
|
||||
description: str
|
||||
notes: str
|
||||
date_added: str
|
||||
date_modified: str
|
||||
tag_names: List[str]
|
||||
to_read: bool
|
||||
private: bool
|
||||
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark = None
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.last_modified = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
@@ -60,9 +62,9 @@ class BookmarkParser(HTMLParser):
|
||||
def handle_start_a(self, attrs: Dict[str, str]):
|
||||
vars(self).update(attrs)
|
||||
tag_names = parse_tag_string(self.tags)
|
||||
archived = "linkding:archived" in self.tags
|
||||
archived = "linkding:bookmarks.archived" in self.tags
|
||||
try:
|
||||
tag_names.remove("linkding:archived")
|
||||
tag_names.remove("linkding:bookmarks.archived")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
|
||||
description="",
|
||||
notes="",
|
||||
date_added=self.add_date,
|
||||
date_modified=self.last_modified,
|
||||
tag_names=tag_names,
|
||||
to_read=self.toread == "1",
|
||||
# Mark as private by default, also when attribute is not specified
|
||||
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark = None
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.last_modified = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_snapshot(url: str, filepath: str):
|
||||
singlefile_path = settings.LD_SINGLEFILE_PATH
|
||||
|
||||
# parse options to list of arguments
|
||||
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
|
||||
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
|
||||
temp_filepath = filepath + ".tmp"
|
||||
# concat lists
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
|
||||
try:
|
||||
# Use start_new_session=True to create a new process group
|
||||
process = subprocess.Popen(args, start_new_session=True)
|
||||
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
|
||||
|
||||
# check if the file was created
|
||||
if not os.path.exists(temp_filepath):
|
||||
if not os.path.exists(filepath):
|
||||
raise SingleFileError("Failed to create snapshot")
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
shutil.copyfileobj(raw_file, gz_file)
|
||||
|
||||
os.remove(temp_filepath)
|
||||
except subprocess.TimeoutExpired:
|
||||
# First try to terminate properly
|
||||
try:
|
||||
|
@@ -1,22 +1,20 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import waybackpy
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone, formats
|
||||
from django.utils import timezone
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from huey.exceptions import TaskLockedException
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
from bookmarks.services import assets, favicon_loader, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT, load_website_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,7 +157,7 @@ def schedule_bookmarks_without_favicons(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
@@ -175,7 +173,7 @@ def schedule_refresh_favicons(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_refresh_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(owner=user)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
@@ -214,7 +212,7 @@ def schedule_bookmarks_without_previews(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(
|
||||
Q(preview_image_file__exact=""),
|
||||
owner=user,
|
||||
@@ -228,6 +226,31 @@ def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
logging.exception(exc)
|
||||
|
||||
|
||||
def refresh_metadata(bookmark: Bookmark):
|
||||
if not settings.LD_DISABLE_BACKGROUND_TASKS:
|
||||
_refresh_metadata_task(bookmark.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _refresh_metadata_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f"Refresh metadata for bookmark. url={bookmark.url}")
|
||||
|
||||
metadata = load_website_metadata(bookmark.url)
|
||||
if metadata.title:
|
||||
bookmark.title = metadata.title
|
||||
if metadata.description:
|
||||
bookmark.description = metadata.description
|
||||
bookmark.date_modified = timezone.now()
|
||||
|
||||
bookmark.save()
|
||||
logger.info(f"Successfully refreshed metadata for bookmark. url={bookmark.url}")
|
||||
|
||||
|
||||
def is_html_snapshot_feature_active() -> bool:
|
||||
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
@@ -236,7 +259,7 @@ def create_html_snapshot(bookmark: Bookmark):
|
||||
if not is_html_snapshot_feature_active():
|
||||
return
|
||||
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
|
||||
|
||||
@@ -246,47 +269,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
|
||||
|
||||
assets_to_create = []
|
||||
for bookmark in bookmark_list:
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
assets_to_create.append(asset)
|
||||
|
||||
BookmarkAsset.objects.bulk_create(assets_to_create)
|
||||
|
||||
|
||||
MAX_SNAPSHOT_FILENAME_LENGTH = 192
|
||||
|
||||
|
||||
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
content_type="text/html",
|
||||
display_name=f"HTML snapshot from {timestamp}",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
|
||||
def sanitize_char(char):
|
||||
if char.isalnum() or char in ("-", "_", "."):
|
||||
return char
|
||||
else:
|
||||
return "_"
|
||||
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
|
||||
|
||||
# Calculate the length of the non-URL parts of the filename
|
||||
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
|
||||
# Calculate the maximum length for the URL part
|
||||
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
|
||||
# Truncate the URL if necessary
|
||||
sanitized_url = sanitized_url[:max_url_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
|
||||
|
||||
|
||||
# singe-file does not support running multiple instances in parallel, so we can
|
||||
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic
|
||||
# task that grabs a number of pending assets and creates snapshots for them in
|
||||
@@ -313,13 +301,8 @@ def _create_html_snapshot_task(asset_id: int):
|
||||
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
|
||||
|
||||
try:
|
||||
filename = _generate_snapshot_filename(asset)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
singlefile.create_snapshot(asset.bookmark.url, filepath)
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
assets.create_snapshot(asset)
|
||||
|
||||
logger.info(
|
||||
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
|
||||
)
|
||||
@@ -328,8 +311,6 @@ def _create_html_snapshot_task(asset_id: int):
|
||||
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
|
||||
exc_info=error,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
|
||||
|
||||
def create_missing_html_snapshots(user: User) -> int:
|
||||
|
@@ -14,8 +14,8 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class WebsiteMetadata:
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
title: str | None
|
||||
description: str | None
|
||||
preview_image: str | None
|
||||
|
||||
def to_dict(self):
|
||||
@@ -27,10 +27,20 @@ class WebsiteMetadata:
|
||||
}
|
||||
|
||||
|
||||
def load_website_metadata(url: str, ignore_cache: bool = False):
|
||||
if ignore_cache:
|
||||
return _load_website_metadata(url)
|
||||
return _load_website_metadata_cached(url)
|
||||
|
||||
|
||||
# Caching metadata avoids scraping again when saving bookmarks, in case the
|
||||
# metadata was already scraped to show preview values in the bookmark form
|
||||
@lru_cache(maxsize=10)
|
||||
def load_website_metadata(url: str):
|
||||
def _load_website_metadata_cached(url: str):
|
||||
return _load_website_metadata(url)
|
||||
|
||||
|
||||
def _load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
@@ -43,7 +53,8 @@ def load_website_metadata(url: str):
|
||||
start = timezone.now()
|
||||
soup = BeautifulSoup(page_text, "html.parser")
|
||||
|
||||
title = soup.title.string.strip() if soup.title is not None else None
|
||||
if soup.title and soup.title.string:
|
||||
title = soup.title.string.strip()
|
||||
description_tag = soup.find("meta", attrs={"name": "description"})
|
||||
description = (
|
||||
description_tag["content"].strip()
|
||||
|
@@ -58,7 +58,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "siteroot.urls"
|
||||
ROOT_URLCONF = "bookmarks.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
@@ -80,7 +80,7 @@ TEMPLATES = [
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
WSGI_APPLICATION = "siteroot.wsgi.application"
|
||||
WSGI_APPLICATION = "bookmarks.wsgi.application"
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
@@ -128,18 +126,10 @@ STATIC_URL = "/" + LD_CONTEXT_PATH + "static/"
|
||||
# Collect static files in static folder
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
# Resolve theme files from style source folder
|
||||
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
||||
# Resolve downloaded files in dev environment
|
||||
os.path.join(BASE_DIR, "data", "favicons"),
|
||||
os.path.join(BASE_DIR, "data", "previews"),
|
||||
]
|
||||
|
||||
# REST framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
"bookmarks.api.auth.LinkdingTokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||
@@ -154,6 +144,7 @@ ALLOW_REGISTRATION = False
|
||||
LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -161,6 +152,7 @@ LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
|
||||
LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -187,7 +179,7 @@ HUEY = {
|
||||
|
||||
|
||||
# Enable OICD support if configured
|
||||
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1")
|
||||
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "true", "1")
|
||||
|
||||
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
|
||||
|
||||
@@ -202,11 +194,18 @@ if LD_ENABLE_OIDC:
|
||||
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
||||
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
|
||||
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "true", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "true", "1")
|
||||
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
|
||||
|
||||
# Enable authentication proxy support if configured
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
|
||||
"LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER"
|
||||
)
|
||||
@@ -273,6 +272,7 @@ LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")
|
||||
LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -294,6 +294,13 @@ LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
|
||||
LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
|
||||
@@ -304,7 +311,7 @@ LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
|
||||
'--browser-arg="--headless=new"',
|
||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||
'--browser-arg="--no-sandbox"',
|
||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
||||
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||
]
|
||||
),
|
||||
)
|
@@ -20,6 +20,14 @@ INTERNAL_IPS = [
|
||||
# Allow access through ngrok
|
||||
CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"]
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
# Resolve theme files from style source folder
|
||||
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
||||
# Resolve downloaded files in dev environment
|
||||
os.path.join(BASE_DIR, "data", "favicons"),
|
||||
os.path.join(BASE_DIR, "data", "previews"),
|
||||
]
|
||||
|
||||
# Enable debug logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
1
bookmarks/static/preview-placeholder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>
|
After Width: | Height: | Size: 535 B |
2
bookmarks/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
@@ -1,5 +1,13 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
.title {
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -12,7 +20,8 @@
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink img, & a.weblink svg {
|
||||
& a.weblink img,
|
||||
& a.weblink svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -27,7 +36,7 @@
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
margin: var(--unit-4 0);
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
@@ -35,57 +44,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
& .sections section {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
|
||||
& .sections h3 {
|
||||
margin-bottom: var(--unit-2);
|
||||
font-size: var(--font-size);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .asset-text .filesize {
|
||||
& .filesize {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
& .asset-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .assets-actions {
|
||||
@@ -110,7 +84,8 @@
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .status .form-group, .status .form-switch {
|
||||
& .status .form-group,
|
||||
.status .form-switch {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
@@ -1,37 +1,37 @@
|
||||
.bookmarks-form-page {
|
||||
section {
|
||||
main {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-form {
|
||||
& .btn.btn-link.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
--btn-icon-color: var(--tertiary-text-color);
|
||||
|
||||
& > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
& .has-icon-right > input, & .has-icon-right > textarea {
|
||||
& .has-icon-right > input,
|
||||
& .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
& .has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
& .has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
& .form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& .form-group .suffix-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
height: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .form-group .clear-button,
|
||||
& .form-group #refresh-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .form-group input.modified,
|
||||
& .form-group textarea.modified {
|
||||
background: var(--primary-color-shade);
|
||||
}
|
||||
|
||||
& .form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: var(--warning-color);
|
||||
@@ -45,4 +45,4 @@
|
||||
& details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,457 +1,574 @@
|
||||
:root {
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--gray-50);
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--gray-50);
|
||||
}
|
||||
|
||||
/* Bookmark page grid */
|
||||
.bookmarks-page.grid {
|
||||
.bookmarks-page {
|
||||
&.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
section.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse-side-panel {
|
||||
main {
|
||||
grid-column: span var(--grid-columns);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .search-container {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
max-width: 300px;
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
max-width: 300px;
|
||||
margin-left: auto;
|
||||
|
||||
& form {
|
||||
width: 100%;
|
||||
}
|
||||
& form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
max-width: initial;
|
||||
margin-left: 0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
max-width: initial;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Regular input */
|
||||
/* Regular input */
|
||||
|
||||
& input[type='search'] {
|
||||
height: var(--control-size);
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Enhanced auto-complete input */
|
||||
/* This needs a bit more wrangling to make the CSS component align with the attached button */
|
||||
|
||||
& .form-autocomplete {
|
||||
height: var(--control-size);
|
||||
|
||||
& .form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: var(--control-size);
|
||||
|
||||
& input[type='search'] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Group search options button with search button */
|
||||
& input[type="search"] {
|
||||
height: var(--control-size);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow-xs);
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
& input, & .form-autocomplete-input {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow: none;
|
||||
/* Enhanced auto-complete input */
|
||||
/* This needs a bit more wrangling to make the CSS component align with the attached button */
|
||||
|
||||
& .form-autocomplete {
|
||||
height: var(--control-size);
|
||||
|
||||
& .form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: var(--control-size);
|
||||
|
||||
& input[type="search"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Group search options button with search button */
|
||||
height: var(--control-size);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow-xs);
|
||||
|
||||
& input,
|
||||
& .form-autocomplete-input {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
& .dropdown-toggle {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
box-shadow: none;
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
/* Search option menu styles */
|
||||
|
||||
& .dropdown {
|
||||
& .menu {
|
||||
padding: var(--unit-4);
|
||||
min-width: 250px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .dropdown-toggle {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
box-shadow: none;
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
& .menu .actions {
|
||||
margin-top: var(--unit-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Search option menu styles */
|
||||
|
||||
& .dropdown {
|
||||
& .menu {
|
||||
padding: var(--unit-4);
|
||||
min-width: 250px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .menu .actions {
|
||||
margin-top: var(--unit-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
& .form-group:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& .form-group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
|
||||
& .radio-group {
|
||||
& .form-label {
|
||||
margin-bottom: var(--unit-1);
|
||||
}
|
||||
|
||||
& .form-radio.form-inline {
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: var(--unit-1);
|
||||
}
|
||||
|
||||
& .form-icon {
|
||||
top: 0;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
& .form-group:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& .form-group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
|
||||
& .radio-group {
|
||||
& .form-label {
|
||||
margin-bottom: var(--unit-1);
|
||||
}
|
||||
|
||||
& .form-radio.form-inline {
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: var(--unit-1);
|
||||
}
|
||||
|
||||
& .form-icon {
|
||||
top: 0;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--unit-3);
|
||||
|
||||
& .content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: var(--unit-h);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--border-color);
|
||||
object-fit: cover;
|
||||
|
||||
&.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--body-color-contrast);
|
||||
|
||||
& .img {
|
||||
width: var(--unit-12);
|
||||
height: var(--unit-12);
|
||||
background-color: var(--tertiary-text-color);
|
||||
-webkit-mask: url(preview-placeholder.svg) no-repeat center;
|
||||
mask: url(preview-placeholder.svg) no-repeat center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .title img {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
& .title img + a {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
& .title a {
|
||||
color: var(--bookmark-title-color);
|
||||
font-weight: var(--bookmark-title-weight);
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .title a[data-tooltip]:hover::after,
|
||||
& .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: var(--unit-1);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #424a8c;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
& .title a[data-tooltip]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
& .url-path,
|
||||
& .url-display {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--secondary-link-color);
|
||||
}
|
||||
|
||||
& .description {
|
||||
color: var(--bookmark-description-color);
|
||||
font-weight: var(--bookmark-description-weight);
|
||||
}
|
||||
|
||||
& .description.separate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions,
|
||||
& .extra-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--unit-3);
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
@media (max-width: 600px) {
|
||||
& .extra-actions {
|
||||
width: 100%;
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
& img.preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: var(--unit-h);
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--border-color);
|
||||
}
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .title img {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
& .title img + a {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
& .title a {
|
||||
color: var(--bookmark-title-color);
|
||||
font-weight: var(--bookmark-title-weight);
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .title a[data-tooltip]:hover::after, & .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: var(--unit-1);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #424a8c;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
& .title a[data-tooltip]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
& .url-path, & .url-display {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--secondary-link-color);
|
||||
}
|
||||
|
||||
& .description {
|
||||
color: var(--bookmark-description-color);
|
||||
font-weight: var(--bookmark-description-weight);
|
||||
}
|
||||
|
||||
& .description.separate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .tags {
|
||||
& a, & a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions, & .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& .extra-actions {
|
||||
width: 100%;
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions {
|
||||
color: var(--bookmark-actions-color);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
& a, & button.btn-link {
|
||||
color: var(--bookmark-actions-color);
|
||||
--btn-icon-color: var(--bookmark-actions-color);
|
||||
font-weight: var(--bookmark-actions-weight);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--bookmark-actions-hover-color);
|
||||
--btn-icon-color: var(--bookmark-actions-hover-color);
|
||||
}
|
||||
}
|
||||
& .actions {
|
||||
color: var(--bookmark-actions-color);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
& a,
|
||||
& button.btn-link {
|
||||
color: var(--bookmark-actions-color);
|
||||
--btn-icon-color: var(--bookmark-actions-color);
|
||||
font-weight: var(--bookmark-actions-weight);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--bookmark-actions-hover-color);
|
||||
--btn-icon-color: var(--bookmark-actions-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
/* Remove left padding from first pagination link */
|
||||
/* Remove left padding from first pagination link */
|
||||
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
background: var(--body-color);
|
||||
padding-bottom: var(--unit-h);
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(
|
||||
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||
);
|
||||
width: calc(
|
||||
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
||||
);
|
||||
background: var(--body-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .pagination {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.bundle-menu {
|
||||
list-style-type: none;
|
||||
margin: 0 0 var(--unit-6);
|
||||
|
||||
.bundle-menu-item {
|
||||
margin: 0;
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.bundle-menu-item a {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.bundle-menu-item.selected a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
& .selected-tags {
|
||||
margin-bottom: var(--unit-4);
|
||||
& .selected-tags {
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--error-color);
|
||||
}
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .unselected-tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
& .unselected-tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
& .group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
|
||||
& .highlight-char {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--alternative-color-dark);
|
||||
}
|
||||
& .highlight-char {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--alternative-color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
& .notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: var(--unit-1) 0;
|
||||
overflow-y: auto;
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
& .notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: var(--unit-1) 0;
|
||||
overflow-y: auto;
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
& .notes .markdown {
|
||||
padding: var(--unit-2) var(--unit-3);
|
||||
}
|
||||
& .notes .markdown {
|
||||
padding: var(--unit-2) var(--unit-3);
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
& li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
&.show-notes .notes,
|
||||
& li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark bulk edit */
|
||||
:root {
|
||||
--bulk-edit-toggle-width: 16px;
|
||||
--bulk-edit-toggle-offset: 8px;
|
||||
--bulk-edit-bar-offset: calc(var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset)));
|
||||
--bulk-edit-transition-duration: 400ms;
|
||||
--bulk-edit-toggle-width: 16px;
|
||||
--bulk-edit-toggle-offset: 8px;
|
||||
--bulk-edit-bar-offset: calc(
|
||||
var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset))
|
||||
);
|
||||
--bulk-edit-transition-duration: 400ms;
|
||||
}
|
||||
|
||||
[ld-bulk-edit] {
|
||||
& .bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
|
||||
margin-bottom: var(--unit-4);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height var(--bulk-edit-transition-duration);
|
||||
background: var(--bulk-actions-bg-color);
|
||||
& .bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
|
||||
margin-bottom: var(--unit-4);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height var(--bulk-edit-transition-duration);
|
||||
background: var(--bulk-actions-bg-color);
|
||||
}
|
||||
|
||||
&.active .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
}
|
||||
|
||||
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
|
||||
|
||||
&.active .main .section-header {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
||||
|
||||
&.active:not(.activating) .bulk-edit-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* make sticky pagination expand to cover checkboxes to the left */
|
||||
|
||||
&.active .bookmark-pagination.sticky:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
left: calc(
|
||||
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||
);
|
||||
width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset));
|
||||
background: var(--body-color);
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox.all {
|
||||
display: block;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
margin: 0 0 0 var(--bulk-edit-toggle-offset);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
|
||||
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
min-height: var(--bulk-edit-toggle-width);
|
||||
left: calc(
|
||||
-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset)
|
||||
);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all var(--bulk-edit-transition-duration);
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
& .bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--unit-1) 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
gap: var(--unit-2);
|
||||
|
||||
& button {
|
||||
--control-padding-x-sm: 0;
|
||||
}
|
||||
|
||||
&.active .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
& button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
|
||||
&.active section:first-of-type .content-area-header {
|
||||
border-bottom-color: transparent;
|
||||
& > input,
|
||||
& .form-autocomplete,
|
||||
& select {
|
||||
width: auto;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
||||
|
||||
&.active:not(.activating) .bulk-edit-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox.all {
|
||||
display: block;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
margin: 0 0 0 var(--bulk-edit-toggle-offset);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
|
||||
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
min-height: var(--bulk-edit-toggle-width);
|
||||
left: calc(-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset));
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all var(--bulk-edit-transition-duration);
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
& .bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--unit-1) 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
gap: var(--unit-2);
|
||||
|
||||
& button {
|
||||
--control-padding-x-sm: 0;
|
||||
}
|
||||
|
||||
& button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
& > input,
|
||||
& .form-autocomplete,
|
||||
& select {
|
||||
width: auto;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
& .select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
& .select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
34
bookmarks/styles/bundles.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.bundles-page {
|
||||
h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
.list-item .list-item-icon {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.list-item.drag-start {
|
||||
--secondary-border-color: transparent;
|
||||
}
|
||||
|
||||
.list-item.dragging > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bundles-editor-page {
|
||||
&.grid {
|
||||
gap: var(--unit-9);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
background: var(--body-color);
|
||||
padding: var(--unit-3) 0;
|
||||
}
|
||||
}
|
@@ -1,65 +1,119 @@
|
||||
/* Shared components */
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
/* Section header component */
|
||||
.section-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
section.content-area .content-area-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.section-header:not(.no-wrap) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
color: var(--error-color) !important;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-5) 0;
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-5) 0;
|
||||
}
|
||||
|
||||
/* Turbo progress bar */
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.message-list {
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
.toast {
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Item list */
|
||||
.item-list {
|
||||
& .list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .list-item-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .list-item-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .list-item-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,39 +1,29 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: 600px) {
|
||||
/* Horizontal offset accounts for checkboxes that show up in bulk edit mode */
|
||||
margin: 20px 32px;
|
||||
}
|
||||
@media (min-width: 600px) {
|
||||
/* Horizontal offset accounts for checkboxes that show up in bulk edit mode */
|
||||
margin: 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--unit-9);
|
||||
margin-bottom: var(--unit-9);
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
a.app-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 var(--unit-3);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
.app-name {
|
||||
margin-left: var(--unit-3);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
@@ -1,40 +1,46 @@
|
||||
.markdown {
|
||||
& p, & ul, & ol, & pre, & blockquote {
|
||||
margin: 0 0 var(--unit-2) 0;
|
||||
}
|
||||
& p,
|
||||
& ul,
|
||||
& ol,
|
||||
& pre,
|
||||
& blockquote {
|
||||
margin: 0 0 var(--unit-2) 0;
|
||||
}
|
||||
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& ul, & ol {
|
||||
margin-left: var(--unit-4);
|
||||
}
|
||||
& ul,
|
||||
& ol {
|
||||
margin-left: var(--unit-4);
|
||||
}
|
||||
|
||||
& ul li, & ol li {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
& ul li,
|
||||
& ol li {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
|
||||
& pre {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
background-color: var(--code-bg-color);
|
||||
border-radius: var(--unit-1);
|
||||
overflow-x: auto;
|
||||
}
|
||||
& pre {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
background-color: var(--code-bg-color);
|
||||
border-radius: var(--unit-1);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
& pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
& pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& > pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
& > pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
@@ -24,4 +24,3 @@ html.reader-mode {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,8 +1,14 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--unit-10);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +18,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
.input-group > input[type="submit"] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
@@ -1,143 +1,150 @@
|
||||
@import "theme-light.css";
|
||||
|
||||
:root {
|
||||
/* Color palette */
|
||||
--contrast-5: hsla(241, 65%, 85%, 0.06);
|
||||
--contrast-10: hsla(241, 60%, 80%, 0.14);
|
||||
--contrast-20: hsla(241, 64%, 82%, 0.23);
|
||||
--contrast-30: hsla(241, 69%, 84%, 0.32);
|
||||
--contrast-40: hsla(241, 73%, 86%, 0.41);
|
||||
--contrast-50: hsla(241, 78%, 88%, 0.5);
|
||||
--contrast-60: hsla(241, 82%, 90%, 0.58);
|
||||
--contrast-70: hsla(241, 87%, 92%, 0.69);
|
||||
--contrast-80: hsla(241, 91%, 94%, 0.8);
|
||||
--contrast-90: hsla(241, 96%, 96%, 0.9);
|
||||
/* Color palette */
|
||||
--contrast-5: hsla(241, 65%, 85%, 0.06);
|
||||
--contrast-10: hsla(241, 60%, 80%, 0.14);
|
||||
--contrast-20: hsla(241, 64%, 82%, 0.23);
|
||||
--contrast-30: hsla(241, 69%, 84%, 0.32);
|
||||
--contrast-40: hsla(241, 73%, 86%, 0.41);
|
||||
--contrast-50: hsla(241, 78%, 88%, 0.5);
|
||||
--contrast-60: hsla(241, 82%, 90%, 0.58);
|
||||
--contrast-70: hsla(241, 87%, 92%, 0.69);
|
||||
--contrast-80: hsla(241, 91%, 94%, 0.8);
|
||||
--contrast-90: hsla(241, 96%, 96%, 0.9);
|
||||
|
||||
--primary-color: hsl(241, 75%, 64%);
|
||||
--primary-color-highlight: hsl(241, 75%, 68%);
|
||||
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
|
||||
--primary-color: hsl(241, 75%, 64%);
|
||||
--primary-color-highlight: hsl(241, 75%, 68%);
|
||||
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
|
||||
|
||||
--alternative-color: hsl(179, 50%, 58%);
|
||||
--alternative-color-dark: hsl(179, 80%, 75%);
|
||||
--alternative-color: hsl(179, 50%, 58%);
|
||||
--alternative-color-dark: hsl(179, 80%, 75%);
|
||||
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
|
||||
--error-color: hsl(0, 80%, 60%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
--error-color: hsl(0, 80%, 60%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-300);
|
||||
--secondary-text-color: var(--gray-400);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 82%, 82%);
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-300);
|
||||
--secondary-text-color: var(--gray-400);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 82%, 82%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
|
||||
|
||||
--icon-color: var(--text-color);
|
||||
--icon-color: var(--text-color);
|
||||
|
||||
--border-color: var(--contrast-30);
|
||||
--secondary-border-color: var(--contrast-20);
|
||||
--border-color: var(--contrast-30);
|
||||
--secondary-border-color: var(--contrast-20);
|
||||
|
||||
--body-color: hsl(241, 15%, 14%);
|
||||
--body-color-contrast: var(--contrast-10);
|
||||
--body-color: hsl(241, 15%, 14%);
|
||||
--body-color-contrast: var(--contrast-10);
|
||||
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid hsl(241, 100%, 78%);
|
||||
--focus-outline-offset: 2px;
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid hsl(241, 100%, 78%);
|
||||
--focus-outline-offset: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--box-shadow-xs: none;
|
||||
--box-shadow: none;
|
||||
--box-shadow-lg: none;
|
||||
/* Shadows */
|
||||
--box-shadow-xs: none;
|
||||
--box-shadow: none;
|
||||
--box-shadow-lg: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--input-bg-color: var(--contrast-5);
|
||||
--input-disabled-bg-color: var(--contrast-30);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
--input-bg-color: var(--contrast-5);
|
||||
--input-disabled-bg-color: var(--contrast-30);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--checkbox-bg-color: var(--contrast-10);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--contrast-30);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
--checkbox-bg-color: var(--contrast-10);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--contrast-30);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
|
||||
--switch-bg-color: var(--contrast-10);
|
||||
--switch-border-color: var(--border-color);
|
||||
--switch-toggle-color: var(--text-color);
|
||||
--switch-bg-color: var(--contrast-10);
|
||||
--switch-border-color: var(--border-color);
|
||||
--switch-toggle-color: var(--text-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--btn-bg-color: var(--contrast-5);
|
||||
--btn-hover-bg-color: var(--contrast-20);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
--btn-bg-color: var(--contrast-5);
|
||||
--btn-hover-bg-color: var(--contrast-20);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
|
||||
--modal-container-bg-color: hsl(241, 20%, 20%);
|
||||
--modal-container-border-color: var(--contrast-30);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: none;
|
||||
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
|
||||
--modal-container-bg-color: hsl(241, 20%, 20%);
|
||||
--modal-container-border-color: var(--contrast-30);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--menu-bg-color: hsl(241, 20%, 20%);
|
||||
--menu-border-color: var(--contrast-30);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: none;
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--contrast-20);
|
||||
--menu-bg-color: hsl(241, 20%, 20%);
|
||||
--menu-border-color: var(--contrast-30);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: none;
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--contrast-20);
|
||||
}
|
||||
|
||||
:root {
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-text-color);
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--contrast-5);
|
||||
}
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--contrast-5);
|
||||
}
|
||||
|
||||
/* Try to force dark color scheme for all native elements (e.g. upload button
|
||||
in file inputs, native select dropdown). For the select dropdown some browsers
|
||||
ignore this and use whatever users have configured in their system settings. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
@@ -28,3 +28,4 @@
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
||||
@import "bundles.css";
|
||||
|
@@ -61,7 +61,8 @@ h1 {
|
||||
|
||||
figcaption,
|
||||
figure,
|
||||
main { /* 1 */
|
||||
main {
|
||||
/* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -256,7 +257,8 @@ textarea {
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
input {
|
||||
/* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -266,7 +268,8 @@ input { /* 1 */
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
select {
|
||||
/* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -299,7 +302,6 @@ button::-moz-focus-inner,
|
||||
* Restore the focus styles unset by the previous rule (removed).
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Change the border, margin, and padding in all browsers (opinionated) (changed).
|
||||
*/
|
||||
|
@@ -1,38 +1,38 @@
|
||||
/* Animations */
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(-1 * var(--unit-8)));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(-1 * var(--unit-8)));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
@@ -3,41 +3,41 @@ html:lang(zh),
|
||||
html:lang(zh-Hans),
|
||||
.lang-zh,
|
||||
.lang-zh-hans {
|
||||
font-family: var(--cjk-zh-hans-font-family);
|
||||
font-family: var(--cjk-zh-hans-font-family);
|
||||
}
|
||||
|
||||
html:lang(zh-Hant),
|
||||
.lang-zh-hant {
|
||||
font-family: var(--cjk-zh-hant-font-family);
|
||||
font-family: var(--cjk-zh-hant-font-family);
|
||||
}
|
||||
|
||||
html:lang(ja),
|
||||
.lang-ja {
|
||||
font-family: var(--cjk-jp-font-family);
|
||||
font-family: var(--cjk-jp-font-family);
|
||||
}
|
||||
|
||||
html:lang(ko),
|
||||
.lang-ko {
|
||||
font-family: var(--cjk-ko-font-family);
|
||||
font-family: var(--cjk-ko-font-family);
|
||||
}
|
||||
|
||||
:lang(zh),
|
||||
:lang(ja),
|
||||
.lang-cjk {
|
||||
& ins,
|
||||
& u {
|
||||
border-bottom: var(--border-width) solid;
|
||||
text-decoration: none;
|
||||
}
|
||||
& ins,
|
||||
& u {
|
||||
border-bottom: var(--border-width) solid;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
& del + del,
|
||||
& del + s,
|
||||
& ins + ins,
|
||||
& ins + u,
|
||||
& s + del,
|
||||
& s + s,
|
||||
& u + ins,
|
||||
& u + u {
|
||||
margin-left: .125em;
|
||||
}
|
||||
& del + del,
|
||||
& del + s,
|
||||
& ins + ins,
|
||||
& ins + u,
|
||||
& s + del,
|
||||
& s + s,
|
||||
& u + ins,
|
||||
& u + u {
|
||||
margin-left: 0.125em;
|
||||
}
|
||||
}
|
||||
|
@@ -1,55 +1,57 @@
|
||||
/* Autocomplete */
|
||||
.form-autocomplete {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
& .form-autocomplete-input {
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
& .form-autocomplete-input {
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
& .menu {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
& .form-input {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
|
||||
& .menu-item.selected > a, & .menu-item > a:hover {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
|
||||
& .group-item, & .group-item:hover {
|
||||
color: var(--tertiary-text-color);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .menu {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
|
||||
& .menu-item.selected > a,
|
||||
& .menu-item > a:hover {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
|
||||
& .group-item,
|
||||
& .group-item:hover {
|
||||
color: var(--tertiary-text-color);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,64 +1,64 @@
|
||||
/* Badges */
|
||||
.badge {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-badge],
|
||||
&:not([data-badge]) {
|
||||
&::after {
|
||||
background: var(--primary-color);
|
||||
background-clip: padding-box;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 0 0 1px var(--body-color);
|
||||
color: var(--contrast-text-color);
|
||||
content: attr(data-badge);
|
||||
display: inline-block;
|
||||
transform: translate(-.05rem, -.5rem);
|
||||
}
|
||||
&[data-badge],
|
||||
&:not([data-badge]) {
|
||||
&::after {
|
||||
background: var(--primary-color);
|
||||
background-clip: padding-box;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0 0 1px var(--body-color);
|
||||
color: var(--contrast-text-color);
|
||||
content: attr(data-badge);
|
||||
display: inline-block;
|
||||
transform: translate(-0.05rem, -0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-badge] {
|
||||
&::after {
|
||||
font-size: var(--font-size-sm);
|
||||
height: .9rem;
|
||||
line-height: 1;
|
||||
min-width: .9rem;
|
||||
padding: .1rem .2rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&[data-badge] {
|
||||
&::after {
|
||||
font-size: var(--font-size-sm);
|
||||
height: 0.9rem;
|
||||
line-height: 1;
|
||||
min-width: 0.9rem;
|
||||
padding: 0.1rem 0.2rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-badge]),
|
||||
&[data-badge=""] {
|
||||
&::after {
|
||||
height: 6px;
|
||||
min-width: 6px;
|
||||
padding: 0;
|
||||
width: 6px;
|
||||
}
|
||||
&:not([data-badge]),
|
||||
&[data-badge=""] {
|
||||
&::after {
|
||||
height: 6px;
|
||||
min-width: 6px;
|
||||
padding: 0;
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Buttons */
|
||||
/* Badges for Buttons */
|
||||
|
||||
&.btn {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
&.btn {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Avatars */
|
||||
/* Badges for Avatars */
|
||||
|
||||
&.avatar {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 14.64%;
|
||||
right: 14.64%;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: var(--zindex-1);
|
||||
}
|
||||
&.avatar {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 14.64%;
|
||||
right: 14.64%;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: var(--zindex-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,60 +2,59 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-gutter: stable;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
html {
|
||||
scrollbar-gutter: initial;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: initial;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--body-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--body-font-family);
|
||||
font-size: var(--font-size);
|
||||
overflow-x: hidden;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--body-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--body-font-family);
|
||||
font-size: var(--font-size);
|
||||
overflow-x: hidden;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
color: var(--link-color);
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:hover,
|
||||
a:active,
|
||||
a.active {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
@@ -1,264 +1,268 @@
|
||||
/* Buttons */
|
||||
:root {
|
||||
--btn-bg-color: var(--body-color);
|
||||
--btn-hover-bg-color: var(--gray-50);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
--btn-bg-color: var(--body-color);
|
||||
--btn-hover-bg-color: var(--gray-50);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
background: var(--btn-bg-color);
|
||||
border: var(--border-width) solid var(--btn-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--btn-text-color);
|
||||
font-weight: var(--btn-font-weight);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
box-shadow: var(--btn-box-shadow);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, border 0.2s, box-shadow 0.2s, color 0.2s;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
appearance: none;
|
||||
background: var(--btn-bg-color);
|
||||
border: var(--border-width) solid var(--btn-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--btn-text-color);
|
||||
font-weight: var(--btn-font-weight);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
box-shadow: var(--btn-box-shadow);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border 0.2s,
|
||||
box-shadow 0.2s,
|
||||
color 0.2s;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-hover-bg-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Button Primary */
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--btn-primary-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-primary-text-color);
|
||||
--btn-icon-color: var(--btn-primary-text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-hover-bg-color);
|
||||
text-decoration: none;
|
||||
background: var(--btn-primary-hover-bg-color);
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
&.loading {
|
||||
&::after {
|
||||
border-bottom-color: var(--btn-primary-text-color);
|
||||
border-left-color: var(--btn-primary-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Colors */
|
||||
|
||||
&.btn-success {
|
||||
background: var(--btn-success-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-success-text-color);
|
||||
--btn-icon-color: var(--btn-success-text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-success-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-error {
|
||||
--btn-border-color: var(--error-color);
|
||||
--btn-text-color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
--btn-hover-bg-color: var(--error-color-shade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--btn-link-text-color);
|
||||
--btn-icon-color: var(--btn-link-text-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-link-hover-text-color);
|
||||
--btn-icon-color: var(--btn-link-hover-text-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Primary */
|
||||
/* Button Sizes */
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--btn-primary-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-primary-text-color);
|
||||
--btn-icon-color: var(--btn-primary-text-color);
|
||||
&.btn-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-primary-hover-bg-color);
|
||||
}
|
||||
&.btn-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
&::after {
|
||||
border-bottom-color: var(--btn-primary-text-color);
|
||||
border-left-color: var(--btn-primary-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Button Block */
|
||||
|
||||
/* Button Colors */
|
||||
&.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
background: var(--btn-success-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-success-text-color);
|
||||
--btn-icon-color: var(--btn-success-text-color);
|
||||
/* Button Action */
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-success-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-error {
|
||||
--btn-border-color: var(--error-color);
|
||||
--btn-text-color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
--btn-hover-bg-color: var(--error-color-shade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--btn-link-text-color);
|
||||
--btn-icon-color: var(--btn-link-text-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-link-hover-text-color);
|
||||
--btn-icon-color: var(--btn-link-hover-text-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
&.btn-action {
|
||||
width: var(--control-size);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
&.btn-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
width: var(--control-size-sm);
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
width: var(--control-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Block */
|
||||
/* Button Clear */
|
||||
|
||||
&.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
&.btn-clear {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: currentColor;
|
||||
box-shadow: none;
|
||||
height: var(--unit-5);
|
||||
line-height: var(--unit-4);
|
||||
margin-left: var(--unit-1);
|
||||
margin-right: -2px;
|
||||
opacity: 1;
|
||||
padding: var(--unit-h);
|
||||
text-decoration: none;
|
||||
width: var(--unit-5);
|
||||
|
||||
&::before {
|
||||
content: "\2715";
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Action */
|
||||
/* Wider button */
|
||||
|
||||
&.btn-action {
|
||||
width: var(--control-size);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
&.btn-wide {
|
||||
padding-left: var(--unit-6);
|
||||
padding-right: var(--unit-6);
|
||||
}
|
||||
|
||||
&.btn-sm {
|
||||
width: var(--control-size-sm);
|
||||
}
|
||||
/* Small icon button */
|
||||
|
||||
&.btn-lg {
|
||||
width: var(--control-size-lg);
|
||||
}
|
||||
&.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-h);
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Clear */
|
||||
/* Button icons */
|
||||
|
||||
&.btn-clear {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: currentColor;
|
||||
box-shadow: none;
|
||||
height: var(--unit-5);
|
||||
line-height: var(--unit-4);
|
||||
margin-left: var(--unit-1);
|
||||
margin-right: -2px;
|
||||
opacity: 1;
|
||||
padding: var(--unit-h);
|
||||
text-decoration: none;
|
||||
width: var(--unit-5);
|
||||
|
||||
&::before {
|
||||
content: "\2715";
|
||||
}
|
||||
}
|
||||
|
||||
/* Wider button */
|
||||
|
||||
&.btn-wide {
|
||||
padding-left: var(--unit-6);
|
||||
padding-right: var(--unit-6);
|
||||
}
|
||||
|
||||
/* Small icon button */
|
||||
|
||||
&.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-h);
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button icons */
|
||||
|
||||
& svg {
|
||||
color: var(--btn-icon-color);
|
||||
align-self: center;
|
||||
}
|
||||
& svg {
|
||||
color: var(--btn-icon-color);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button groups */
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 auto;
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-group-block {
|
||||
display: flex;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 auto;
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
&.btn-group-block {
|
||||
display: flex;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,30 +1,30 @@
|
||||
/* Code */
|
||||
:root {
|
||||
--code-bg-color: var(--body-color-contrast);
|
||||
--code-color: var(--text-color);
|
||||
--code-bg-color: var(--body-color-contrast);
|
||||
--code-color: var(--text-color);
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 1.25;
|
||||
padding: .1rem .2rem;
|
||||
background: var(--code-bg-color);
|
||||
color: var(--code-color);
|
||||
font-size: 85%;
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 1.25;
|
||||
padding: 0.1rem 0.2rem;
|
||||
background: var(--code-bg-color);
|
||||
color: var(--code-color);
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.code {
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--code-bg-color);
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--code-bg-color);
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
|
||||
& code {
|
||||
color: inherit;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
padding: var(--unit-2);
|
||||
width: 100%;
|
||||
}
|
||||
& code {
|
||||
color: inherit;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
padding: var(--unit-2);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,12 @@
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
--dropdown-focus-display: block;
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
.menu {
|
||||
animation: fade-in .15s ease 1;
|
||||
animation: fade-in 0.15s ease 1;
|
||||
display: none;
|
||||
left: 0;
|
||||
max-height: 50vh;
|
||||
@@ -20,9 +22,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu,
|
||||
.dropdown-toggle:focus + .menu,
|
||||
.menu:hover {
|
||||
&:focus-within .menu {
|
||||
/* Use custom CSS property to allow disabling opening on focus when using JS */
|
||||
display: var(--dropdown-focus-display);
|
||||
}
|
||||
|
||||
&.active .menu {
|
||||
/* Always show menu when class is added through JS */
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
@@ -1,21 +1,21 @@
|
||||
/* Empty states (or Blank slates) */
|
||||
.empty {
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
padding: var(--unit-16) var(--unit-8);
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
padding: var(--unit-16) var(--unit-8);
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
.empty-icon {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
.empty-title,
|
||||
.empty-subtitle {
|
||||
margin: var(--layout-spacing) auto;
|
||||
}
|
||||
.empty-title,
|
||||
.empty-subtitle {
|
||||
margin: var(--layout-spacing) auto;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: var(--layout-spacing-lg);
|
||||
}
|
||||
.empty-action {
|
||||
margin-top: var(--layout-spacing-lg);
|
||||
}
|
||||
}
|
||||
|
@@ -1,515 +1,548 @@
|
||||
/* Forms */
|
||||
:root {
|
||||
--input-bg-color: var(--body-color);
|
||||
--input-disabled-bg-color: var(--gray-100);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
--input-bg-color: var(--body-color);
|
||||
--input-disabled-bg-color: var(--gray-100);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--checkbox-bg-color: var(--body-color);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--gray-100);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
--checkbox-bg-color: var(--body-color);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--gray-100);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
|
||||
--switch-bg-color: var(--gray-300);
|
||||
--switch-border-color: var(--gray-400);
|
||||
--switch-toggle-color: #fff;
|
||||
--switch-bg-color: var(--gray-300);
|
||||
--switch-border-color: var(--gray-400);
|
||||
--switch-toggle-color: #fff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&:first-of-type {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
&:first-of-type {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
legend {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
/* Form element: Label */
|
||||
.form-label {
|
||||
display: block;
|
||||
line-height: var(--line-height);
|
||||
margin-bottom: var(--unit-2);
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
line-height: var(--line-height);
|
||||
margin-bottom: var(--unit-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
details summary .form-label {
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
details[open] summary .form-label {
|
||||
margin-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
/* Form element: Input */
|
||||
.form-input {
|
||||
appearance: none;
|
||||
background: var(--input-bg-color);
|
||||
background-image: none;
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
color: var(--input-text-color);
|
||||
display: block;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
position: relative;
|
||||
transition: background 0.2s, border 0.2s, color 0.2s;
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
background: var(--input-bg-color);
|
||||
background-image: none;
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
color: var(--input-text-color);
|
||||
display: block;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
position: relative;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border 0.2s,
|
||||
color 0.2s;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-color);
|
||||
opacity: 1;
|
||||
}
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Input sizes */
|
||||
/* Input sizes */
|
||||
|
||||
&.input-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
&.input-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.input-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
&.input-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
&.input-inline {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
&.input-inline {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Input types */
|
||||
/* Input types */
|
||||
|
||||
&[type="file"] {
|
||||
height: auto;
|
||||
}
|
||||
&[type="file"] {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Textarea */
|
||||
textarea.form-input {
|
||||
&,
|
||||
&.input-lg,
|
||||
&.input-sm {
|
||||
height: auto;
|
||||
}
|
||||
&,
|
||||
&.input-lg,
|
||||
&.input-sm {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Input hint */
|
||||
.form-input-hint {
|
||||
color: var(--input-hint-color);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--unit-1);
|
||||
color: var(--input-hint-color);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--unit-1);
|
||||
|
||||
.has-success &,
|
||||
.is-success + & {
|
||||
color: var(--success-color);
|
||||
}
|
||||
.has-success &,
|
||||
.is-success + & {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.has-error &,
|
||||
.is-error + & {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.has-error &,
|
||||
.is-error + & {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Select */
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background: var(--input-bg-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
color: var(--input-text-color);
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background: var(--input-bg-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
color: var(--input-text-color);
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
/* Select sizes */
|
||||
|
||||
&.select-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm)
|
||||
calc(var(--control-icon-size) + var(--control-padding-x-sm))
|
||||
var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.select-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg)
|
||||
calc(var(--control-icon-size) + var(--control-padding-x-lg))
|
||||
var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
/* Multiple select */
|
||||
|
||||
&[size],
|
||||
&[multiple] {
|
||||
height: auto;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
& option {
|
||||
padding: var(--unit-h) var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Select sizes */
|
||||
&:not([multiple]):not([size]) {
|
||||
background: var(--input-bg-color)
|
||||
url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E")
|
||||
no-repeat right 0.35rem center / 0.4rem 0.5rem;
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
}
|
||||
|
||||
&.select-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) calc(var(--control-icon-size) + var(--control-padding-x-sm)) var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.select-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) calc(var(--control-icon-size) + var(--control-padding-x-lg)) var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
/* Multiple select */
|
||||
|
||||
&[size],
|
||||
&[multiple] {
|
||||
height: auto;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
|
||||
& option {
|
||||
padding: var(--unit-h) var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
&:not([multiple]):not([size]) {
|
||||
background: var(--input-bg-color) url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center / .4rem .5rem;
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
}
|
||||
/* Options */
|
||||
& option {
|
||||
/* On Windows with Chrome / Edge, options seems to use the same
|
||||
background color as the select. However for the dark theme the
|
||||
background is a semi-transparent white, resulting in an opaque white
|
||||
background for the dropdown. Use the modal background color to force
|
||||
a dark background instead. */
|
||||
background: var(--modal-container-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Checkbox and Radio */
|
||||
.form-checkbox,
|
||||
.form-radio,
|
||||
.form-switch {
|
||||
display: block;
|
||||
line-height: var(--line-height);
|
||||
margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;
|
||||
min-height: var(--control-size-sm);
|
||||
padding: calc((var(--control-size-sm) - var(--line-height)) / 2) var(--control-padding-x) calc((var(--control-size-sm) - var(--line-height)) / 2) calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
position: relative;
|
||||
display: block;
|
||||
line-height: var(--line-height);
|
||||
margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;
|
||||
min-height: var(--control-size-sm);
|
||||
padding: calc((var(--control-size-sm) - var(--line-height)) / 2)
|
||||
var(--control-padding-x)
|
||||
calc((var(--control-size-sm) - var(--line-height)) / 2)
|
||||
calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
input {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||
left: 0;
|
||||
height: var(--control-icon-size);
|
||||
width: var(--control-icon-size);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible + .form-icon {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
&:checked + .form-icon {
|
||||
background: var(--checkbox-checked-bg-color);
|
||||
border-color: var(--checkbox-checked-bg-color);
|
||||
}
|
||||
&:focus-visible + .form-icon {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
border: var(--border-width) solid var(--checkbox-border-color);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition: background .2s, border .2s, color .2s;
|
||||
&:checked + .form-icon {
|
||||
background: var(--checkbox-checked-bg-color);
|
||||
border-color: var(--checkbox-checked-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Input checkbox, radio, and switch sizes */
|
||||
.form-icon {
|
||||
pointer-events: none;
|
||||
border: var(--border-width) solid var(--checkbox-border-color);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
&.input-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
/* Input checkbox, radio, and switch sizes */
|
||||
|
||||
&.input-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;
|
||||
}
|
||||
&.input-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.input-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
.form-icon {
|
||||
background: var(--checkbox-bg-color);
|
||||
height: var(--control-icon-size);
|
||||
left: 0;
|
||||
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||
width: var(--control-icon-size);
|
||||
}
|
||||
.form-icon {
|
||||
background: var(--checkbox-bg-color);
|
||||
height: var(--control-icon-size);
|
||||
left: 0;
|
||||
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||
width: var(--control-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
font-weight: 500;
|
||||
font-weight: 500;
|
||||
|
||||
.form-icon {
|
||||
border-radius: var(--border-radius);
|
||||
.form-icon {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
background-clip: padding-box;
|
||||
border: var(--border-width-lg) solid var(--checkbox-icon-color);
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
content: "";
|
||||
height: 9px;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
margin-top: -6px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: rotate(45deg);
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
background-clip: padding-box;
|
||||
border: var(--border-width-lg) solid var(--checkbox-icon-color);
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
content: "";
|
||||
height: 9px;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
margin-top: -6px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: rotate(45deg);
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
&:indeterminate + .form-icon {
|
||||
background: var(--checkbox-checked-bg-color);
|
||||
border-color: var(--checkbox-checked-bg-color);
|
||||
|
||||
&:indeterminate + .form-icon {
|
||||
background: var(--checkbox-checked-bg-color);
|
||||
border-color: var(--checkbox-checked-bg-color);
|
||||
|
||||
&::before {
|
||||
background: var(--checkbox-icon-color);
|
||||
content: "";
|
||||
height: 2px;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
margin-top: -1px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
background: var(--checkbox-icon-color);
|
||||
content: "";
|
||||
height: 2px;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
margin-top: -1px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
.form-icon {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.form-icon {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
background: var(--checkbox-icon-color);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
height: 6px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
background: var(--checkbox-icon-color);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
height: 6px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Switch */
|
||||
.form-switch {
|
||||
padding-left: calc(var(--unit-8) + var(--control-padding-x));
|
||||
padding-left: calc(var(--unit-8) + var(--control-padding-x));
|
||||
|
||||
.form-icon {
|
||||
background: var(--switch-bg-color);
|
||||
background-clip: padding-box;
|
||||
border-color: var(--switch-border-color);
|
||||
border-radius: calc(var(--unit-2) + var(--border-width));
|
||||
height: calc(var(--unit-4) + var(--border-width) * 2);
|
||||
left: 0;
|
||||
top: calc((var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width));
|
||||
width: var(--unit-8);
|
||||
.form-icon {
|
||||
background: var(--switch-bg-color);
|
||||
background-clip: padding-box;
|
||||
border-color: var(--switch-border-color);
|
||||
border-radius: calc(var(--unit-2) + var(--border-width));
|
||||
height: calc(var(--unit-4) + var(--border-width) * 2);
|
||||
left: 0;
|
||||
top: calc(
|
||||
(var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width)
|
||||
);
|
||||
width: var(--unit-8);
|
||||
|
||||
&::before {
|
||||
background: var(--switch-toggle-color);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
display: block;
|
||||
height: var(--unit-4);
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: background .2s, border .2s, color .2s, left .2s;
|
||||
width: var(--unit-4);
|
||||
}
|
||||
&::before {
|
||||
background: var(--switch-toggle-color);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
display: block;
|
||||
height: var(--unit-4);
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border 0.2s,
|
||||
color 0.2s,
|
||||
left 0.2s;
|
||||
width: var(--unit-4);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Icons */
|
||||
.has-icon-left,
|
||||
.has-icon-right {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
.form-icon {
|
||||
height: var(--control-icon-size);
|
||||
margin: 0 var(--control-padding-y);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--control-icon-size);
|
||||
z-index: calc(var(--zindex-0) + 1);
|
||||
}
|
||||
.form-icon {
|
||||
height: var(--control-icon-size);
|
||||
margin: 0 var(--control-padding-y);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--control-icon-size);
|
||||
z-index: calc(var(--zindex-0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-left {
|
||||
& .form-icon {
|
||||
left: var(--border-width);
|
||||
}
|
||||
& .form-icon {
|
||||
left: var(--border-width);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
& .form-input {
|
||||
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right {
|
||||
& .form-icon {
|
||||
right: var(--border-width);
|
||||
}
|
||||
& .form-icon {
|
||||
right: var(--border-width);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
& .form-input {
|
||||
padding-right: calc(
|
||||
var(--control-icon-size) + var(--control-padding-y) * 2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Form element: Input groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
display: flex;
|
||||
|
||||
.input-group-addon {
|
||||
background: var(--body-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: var(--line-height);
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
white-space: nowrap;
|
||||
.input-group-addon {
|
||||
background: var(--body-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: var(--line-height);
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
white-space: nowrap;
|
||||
|
||||
&.addon-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.addon-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
&.addon-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
flex: 1 1 auto;
|
||||
width: 1%;
|
||||
&.addon-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
flex: 1 1 auto;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.input-group-btn {
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.input-group-addon,
|
||||
.input-group-btn {
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.input-group-btn {
|
||||
z-index: var(--zindex-0);
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.input-group-addon,
|
||||
.input-group-btn {
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:focus {
|
||||
z-index: calc(var(--zindex-0) + 1);
|
||||
}
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: auto;
|
||||
&:focus {
|
||||
z-index: calc(var(--zindex-0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.input-inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
.form-select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&.input-inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form validation states */
|
||||
.form-input,
|
||||
.form-select {
|
||||
.has-success &,
|
||||
&.is-success {
|
||||
background: var(--success-color-shade);
|
||||
border-color: var(--success-color);
|
||||
.has-success &,
|
||||
&.is-success {
|
||||
background: var(--success-color-shade);
|
||||
border-color: var(--success-color);
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--success-color);
|
||||
}
|
||||
&:focus {
|
||||
outline-color: var(--success-color);
|
||||
}
|
||||
}
|
||||
|
||||
.has-error &,
|
||||
&.is-error {
|
||||
background: var(--error-color-shade);
|
||||
border-color: var(--error-color);
|
||||
.has-error &,
|
||||
&.is-error {
|
||||
background: var(--error-color-shade);
|
||||
border-color: var(--error-color);
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--error-color);
|
||||
}
|
||||
&:focus {
|
||||
outline-color: var(--error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form disabled and readonly */
|
||||
.form-input,
|
||||
.form-select {
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
background-color: var(--input-disabled-bg-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
background-color: var(--input-disabled-bg-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
& + .form-icon {
|
||||
background: var(--checkbox-disabled-bg-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
& + .form-icon {
|
||||
background: var(--checkbox-disabled-bg-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Increase input font size on small viewports to prevent zooming on focus the input */
|
||||
/* on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max */
|
||||
/* viewport size */
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
@@ -1,89 +1,90 @@
|
||||
:root {
|
||||
--menu-bg-color: var(--body-color);
|
||||
--menu-border-color: var(--gray-200);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: var(--box-shadow);
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--primary-text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--primary-color-shade);
|
||||
--menu-bg-color: var(--body-color);
|
||||
--menu-border-color: var(--gray-200);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: var(--box-shadow);
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--primary-text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--primary-color-shade);
|
||||
}
|
||||
|
||||
/* Menus */
|
||||
.menu {
|
||||
background: var(--menu-bg-color);
|
||||
border: solid 1px var(--menu-border-color);
|
||||
border-radius: var(--menu-border-radius);
|
||||
box-shadow: var(--menu-box-shadow);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
min-width: var(--control-width-xs);
|
||||
transform: translateY(var(--layout-spacing-sm));
|
||||
z-index: var(--zindex-3);
|
||||
background: var(--menu-bg-color);
|
||||
border: solid 1px var(--menu-border-color);
|
||||
border-radius: var(--menu-border-radius);
|
||||
box-shadow: var(--menu-box-shadow);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
min-width: var(--control-width-xs);
|
||||
transform: translateY(var(--layout-spacing-sm));
|
||||
z-index: var(--zindex-3);
|
||||
|
||||
&.menu-nav {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
&.menu-nav {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin-top: 0;
|
||||
padding: 0 var(--unit-4);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: var(--unit-2);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin-top: 0;
|
||||
padding: 0 var(--unit-4);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: var(--unit-2);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
padding-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
& > a, .btn.btn-link {
|
||||
border-radius: var(--menu-border-radius);
|
||||
color: var(--menu-item-color);
|
||||
background: var(--menu-item-bg-color);
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--unit-2));
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio,
|
||||
.form-switch {
|
||||
margin: var(--unit-h) 0;
|
||||
}
|
||||
|
||||
& + .menu-item {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
&:last-of-type {
|
||||
padding-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
& .menu-badge {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
& > a,
|
||||
.btn.btn-link {
|
||||
border-radius: var(--menu-border-radius);
|
||||
color: var(--menu-item-color);
|
||||
background: var(--menu-item-bg-color);
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--unit-2));
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
text-decoration: none;
|
||||
|
||||
.label {
|
||||
margin-right: var(--unit-2);
|
||||
}
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-2) 0;
|
||||
.form-checkbox,
|
||||
.form-radio,
|
||||
.form-switch {
|
||||
margin: var(--unit-h) 0;
|
||||
}
|
||||
}
|
||||
|
||||
& + .menu-item {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
& .menu-badge {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
.label {
|
||||
margin-right: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
& .divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-2) 0;
|
||||
}
|
||||
}
|
||||
|
@@ -1,103 +1,148 @@
|
||||
/* Modals */
|
||||
:root {
|
||||
--modal-overlay-bg-color: rgba(243, 244, 246, 0.6);
|
||||
--modal-container-bg-color: var(--body-color);
|
||||
--modal-container-border-color: var(--gray-200);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: var(--box-shadow-lg);
|
||||
--modal-overlay-bg-color: rgba(243, 244, 246, 0.6);
|
||||
--modal-container-bg-color: var(--body-color);
|
||||
--modal-container-border-color: var(--gray-200);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: var(--box-shadow-lg);
|
||||
}
|
||||
|
||||
.modal {
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--layout-spacing);
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--layout-spacing);
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
&:target,
|
||||
&.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
z-index: var(--zindex-4);
|
||||
&:target,
|
||||
&.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
z-index: var(--zindex-4);
|
||||
|
||||
& .modal-overlay {
|
||||
animation: fade-in .15s ease 1;
|
||||
background: var(--modal-overlay-bg-color);
|
||||
bottom: 0;
|
||||
cursor: default;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
& .modal-container {
|
||||
animation: fade-in .15s ease 1;
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
& .modal-overlay {
|
||||
animation: fade-in 0.15s ease 1;
|
||||
background: var(--modal-overlay-bg-color);
|
||||
bottom: 0;
|
||||
cursor: default;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-overlay, & .modal-container {
|
||||
animation: fade-out .15s ease 1;
|
||||
}
|
||||
& .modal-container {
|
||||
animation: fade-in 0.15s ease 1;
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-overlay,
|
||||
& .modal-container {
|
||||
animation: fade-out 0.15s ease 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--modal-container-bg-color);
|
||||
border: solid 1px var(--modal-container-border-color);
|
||||
border-radius: var(--modal-border-radius);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
background: var(--modal-container-bg-color);
|
||||
border: solid 1px var(--modal-container-border-color);
|
||||
border-radius: var(--modal-border-radius);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
padding-bottom: 0;
|
||||
color: var(--text-color);
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: .85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
& .close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
& .modal-footer {
|
||||
text-align: right;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-body:not(:has(+ .modal-footer)) {
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.modal.drawer {
|
||||
display: block;
|
||||
|
||||
& .modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border: none;
|
||||
border-left: solid 1px var(--modal-container-border-color);
|
||||
border-radius: 0;
|
||||
transform: translateX(100%);
|
||||
animation: fade-in 0.25s ease 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& .modal-container {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-container {
|
||||
animation: fade-out 0.25s ease 1;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-lock {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
@@ -1,61 +1,61 @@
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0;
|
||||
padding: var(--unit-1) 0;
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0;
|
||||
padding: var(--unit-1) 0;
|
||||
|
||||
& .page-item {
|
||||
margin: var(--unit-1) var(--unit-o);
|
||||
& .page-item {
|
||||
margin: var(--unit-1) var(--unit-o);
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
padding: var(--unit-1) var(--unit-1);
|
||||
}
|
||||
|
||||
& a {
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
& a {
|
||||
cursor: default;
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.page-prev,
|
||||
&.page-next {
|
||||
flex: 1 0 50%;
|
||||
}
|
||||
|
||||
&.page-next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .page-item-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .page-item-subtitle {
|
||||
margin: 0;
|
||||
opacity: .5;
|
||||
}
|
||||
& span {
|
||||
display: inline-block;
|
||||
padding: var(--unit-1) var(--unit-1);
|
||||
}
|
||||
|
||||
& a {
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
& a {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.page-prev,
|
||||
&.page-next {
|
||||
flex: 1 0 50%;
|
||||
}
|
||||
|
||||
&.page-next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .page-item-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .page-item-subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,26 +1,26 @@
|
||||
/* Tables */
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
/* Scrollable tables */
|
||||
/* Scrollable tables */
|
||||
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
}
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
}
|
||||
}
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
}
|
||||
}
|
||||
|
@@ -1,75 +1,76 @@
|
||||
/* Tabs */
|
||||
:root {
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-color);
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab {
|
||||
align-items: center;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
|
||||
|
||||
& .tab-item {
|
||||
margin-top: 0;
|
||||
|
||||
& a {
|
||||
border-bottom: var(--border-width-lg) solid transparent;
|
||||
color: var(--tab-color);
|
||||
display: block;
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: var(--unit-2) var(--unit-1)
|
||||
calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--tab-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active a,
|
||||
& a.active {
|
||||
border-bottom-color: var(--tab-highlight-color);
|
||||
color: var(--tab-active-color);
|
||||
}
|
||||
|
||||
&.tab-action {
|
||||
flex: 1 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .btn-clear {
|
||||
margin-top: calc(-1 * var(--unit-1));
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-block {
|
||||
& .tab-item {
|
||||
margin-top: 0;
|
||||
flex: 1 0 0;
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
border-bottom: var(--border-width-lg) solid transparent;
|
||||
color: var(--tab-color);
|
||||
display: block;
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: var(--unit-2) var(--unit-1) calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
|
||||
text-decoration: none;
|
||||
& a {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--tab-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active a,
|
||||
& a.active {
|
||||
border-bottom-color: var(--tab-highlight-color);
|
||||
color: var(--tab-active-color);
|
||||
}
|
||||
|
||||
&.tab-action {
|
||||
flex: 1 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .btn-clear {
|
||||
margin-top: calc(-1 * var(--unit-1));
|
||||
& .badge {
|
||||
&[data-badge]::after {
|
||||
position: absolute;
|
||||
right: var(--unit-h);
|
||||
top: var(--unit-h);
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-block {
|
||||
& .tab-item {
|
||||
flex: 1 0 0;
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .badge {
|
||||
&[data-badge]::after {
|
||||
position: absolute;
|
||||
right: var(--unit-h);
|
||||
top: var(--unit-h);
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:not(.tab-block) {
|
||||
& .badge {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&:not(.tab-block) {
|
||||
& .badge {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,35 +1,35 @@
|
||||
/* Toasts */
|
||||
.toast {
|
||||
background: var(--gray-600);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--contrast-text-color);
|
||||
display: block;
|
||||
padding: var(--layout-spacing);
|
||||
width: 100%;
|
||||
background: var(--gray-600);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--contrast-text-color);
|
||||
display: block;
|
||||
padding: var(--layout-spacing);
|
||||
width: 100%;
|
||||
|
||||
&.toast-primary {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
&.toast-primary {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
&.toast-success {
|
||||
background: var(--success-color);
|
||||
}
|
||||
&.toast-success {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
&.toast-warning {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
&.toast-warning {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
&.toast-error {
|
||||
background: var(--error-color);
|
||||
}
|
||||
&.toast-error {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
margin: var(--unit-h);
|
||||
}
|
||||
.btn-clear {
|
||||
margin: var(--unit-h);
|
||||
}
|
||||
|
||||
p {
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
p {
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,11 +6,11 @@ h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5em;
|
||||
margin-top: 0;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5em;
|
||||
margin-top: 0;
|
||||
}
|
||||
.h1,
|
||||
.h2,
|
||||
@@ -18,100 +18,100 @@ h6 {
|
||||
.h4,
|
||||
.h5,
|
||||
.h6 {
|
||||
font-weight: 500;
|
||||
font-weight: 500;
|
||||
}
|
||||
h1,
|
||||
.h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
h2,
|
||||
.h2 {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
h3,
|
||||
.h3 {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
h4,
|
||||
.h4 {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h5,
|
||||
.h5 {
|
||||
font-size: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6,
|
||||
.h6 {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
margin: 0 0 var(--line-height);
|
||||
margin: 0 0 var(--line-height);
|
||||
}
|
||||
|
||||
/* Semantic text elements */
|
||||
a,
|
||||
ins,
|
||||
u {
|
||||
text-decoration-skip-ink: auto;
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: var(--border-width) dotted;
|
||||
cursor: help;
|
||||
text-decoration: none;
|
||||
border-bottom: var(--border-width) dotted;
|
||||
cursor: help;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
blockquote {
|
||||
border-left: var(--border-width-lg) solid var(--border-color);
|
||||
margin-left: 0;
|
||||
padding: var(--unit-2) var(--unit-4);
|
||||
border-left: var(--border-width-lg) solid var(--border-color);
|
||||
margin-left: 0;
|
||||
padding: var(--unit-2) var(--unit-4);
|
||||
|
||||
& p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
& p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul,
|
||||
ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
padding: 0;
|
||||
|
||||
& ul,
|
||||
& ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& ul,
|
||||
& ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
}
|
||||
|
||||
& li {
|
||||
margin-top: var(--unit-2);
|
||||
}
|
||||
& li {
|
||||
margin-top: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc inside;
|
||||
list-style: disc inside;
|
||||
|
||||
& ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
& ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal inside;
|
||||
list-style: decimal inside;
|
||||
|
||||
& ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
& ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
& dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
& dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin: var(--unit-1) 0 var(--unit-4) 0;
|
||||
}
|
||||
& dd {
|
||||
margin: var(--unit-1) 0 var(--unit-4) 0;
|
||||
}
|
||||
}
|
||||
|
@@ -1,296 +1,304 @@
|
||||
/* Colors */
|
||||
.text-primary {
|
||||
color: var(--primary-text-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--secondary-text-color);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: var(--tertiary-text-color);
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success-color);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning-color);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error-color);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.icon-color {
|
||||
color: var(--icon-color);
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
/* Display */
|
||||
.d-block {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d-inline {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.d-inline-block {
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.d-flex {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.d-inline-flex {
|
||||
display: inline-flex;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.d-none,
|
||||
.d-hide {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.d-visible {
|
||||
visibility: visible;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.d-invisible {
|
||||
visibility: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.text-hide {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
text-shadow: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.text-assistive {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
color: transparent !important;
|
||||
min-height: var(--unit-4);
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
color: transparent !important;
|
||||
min-height: var(--unit-4);
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
animation: loading 500ms infinite linear;
|
||||
background: transparent;
|
||||
border: var(--border-width-lg) solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
content: "";
|
||||
display: block;
|
||||
height: var(--unit-4);
|
||||
left: 50%;
|
||||
margin-left: calc(-1 * var(--unit-2));
|
||||
margin-top: calc(-1 * var(--unit-2));
|
||||
opacity: 1;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: var(--unit-4);
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
|
||||
&.loading-lg {
|
||||
min-height: var(--unit-10);
|
||||
|
||||
&::after {
|
||||
animation: loading 500ms infinite linear;
|
||||
background: transparent;
|
||||
border: var(--border-width-lg) solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
content: "";
|
||||
display: block;
|
||||
height: var(--unit-4);
|
||||
left: 50%;
|
||||
margin-left: calc(-1 * var(--unit-2));
|
||||
margin-top: calc(-1 * var(--unit-2));
|
||||
opacity: 1;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: var(--unit-4);
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
|
||||
&.loading-lg {
|
||||
min-height: var(--unit-10);
|
||||
|
||||
&::after {
|
||||
height: var(--unit-8);
|
||||
margin-left: calc(-1 * var(--unit-4));
|
||||
margin-top: calc(-1 * var(--unit-4));
|
||||
width: var(--unit-8);
|
||||
}
|
||||
height: var(--unit-8);
|
||||
margin-left: calc(-1 * var(--unit-4));
|
||||
margin-top: calc(-1 * var(--unit-4));
|
||||
width: var(--unit-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Position */
|
||||
.m-0 {
|
||||
margin: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ml-0 {
|
||||
margin-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.mr-0 {
|
||||
margin-right: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.mt-0 {
|
||||
margin-top: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mx-0 {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.my-0 {
|
||||
margin-bottom: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.m-1 {
|
||||
margin: var(--unit-1) !important;
|
||||
margin: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: var(--unit-1) !important;
|
||||
margin-bottom: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: var(--unit-1) !important;
|
||||
margin-left: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: var(--unit-1) !important;
|
||||
margin-right: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: var(--unit-1) !important;
|
||||
margin-top: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mx-1 {
|
||||
margin-left: var(--unit-1) !important;
|
||||
margin-right: var(--unit-1) !important;
|
||||
margin-left: var(--unit-1) !important;
|
||||
margin-right: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.my-1 {
|
||||
margin-bottom: var(--unit-1) !important;
|
||||
margin-top: var(--unit-1) !important;
|
||||
margin-bottom: var(--unit-1) !important;
|
||||
margin-top: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.m-2 {
|
||||
margin: var(--unit-2) !important;
|
||||
margin: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: var(--unit-2) !important;
|
||||
margin-bottom: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: var(--unit-2) !important;
|
||||
margin-left: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: var(--unit-2) !important;
|
||||
margin-right: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: var(--unit-2) !important;
|
||||
margin-top: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mx-2 {
|
||||
margin-left: var(--unit-2) !important;
|
||||
margin-right: var(--unit-2) !important;
|
||||
margin-left: var(--unit-2) !important;
|
||||
margin-right: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.my-2 {
|
||||
margin-bottom: var(--unit-2) !important;
|
||||
margin-top: var(--unit-2) !important;
|
||||
margin-bottom: var(--unit-2) !important;
|
||||
margin-top: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.m-4 {
|
||||
margin: var(--unit-4) !important;
|
||||
margin: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: var(--unit-4) !important;
|
||||
margin-bottom: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: var(--unit-4) !important;
|
||||
margin-left: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mr-4 {
|
||||
margin-right: var(--unit-4) !important;
|
||||
margin-right: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: var(--unit-4) !important;
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mx-4 {
|
||||
margin-left: var(--unit-4) !important;
|
||||
margin-right: var(--unit-4) !important;
|
||||
margin-left: var(--unit-4) !important;
|
||||
margin-right: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.my-4 {
|
||||
margin-bottom: var(--unit-4) !important;
|
||||
margin-top: var(--unit-4) !important;
|
||||
margin-bottom: var(--unit-4) !important;
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Text */
|
||||
.text-normal {
|
||||
font-weight: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-italic {
|
||||
font-style: italic;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-large {
|
||||
font-size: 1.2em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: .9em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.text-tiny {
|
||||
font-size: .8em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
opacity: .8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Flex */
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
@@ -1,135 +1,150 @@
|
||||
:root {
|
||||
/* Color palette */
|
||||
--gray-50: rgb(249, 250, 251);
|
||||
--gray-100: rgb(243, 244, 246);
|
||||
--gray-200: rgb(229, 231, 235);
|
||||
--gray-300: rgb(209, 213, 219);
|
||||
--gray-400: rgb(156, 163, 175);
|
||||
--gray-500: rgb(107, 114, 128);
|
||||
--gray-600: rgb(75, 85, 99);
|
||||
--gray-700: rgb(55, 65, 81);
|
||||
--gray-800: rgb(31, 41, 55);
|
||||
--gray-900: rgb(17, 24, 39);
|
||||
/* Color palette */
|
||||
--gray-50: rgb(249, 250, 251);
|
||||
--gray-100: rgb(243, 244, 246);
|
||||
--gray-200: rgb(229, 231, 235);
|
||||
--gray-300: rgb(209, 213, 219);
|
||||
--gray-400: rgb(156, 163, 175);
|
||||
--gray-500: rgb(107, 114, 128);
|
||||
--gray-600: rgb(75, 85, 99);
|
||||
--gray-700: rgb(55, 65, 81);
|
||||
--gray-800: rgb(31, 41, 55);
|
||||
--gray-900: rgb(17, 24, 39);
|
||||
|
||||
--primary-color: hsl(241, 63%, 59%);
|
||||
--primary-color-highlight: hsl(241, 63%, 64%);
|
||||
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
|
||||
--primary-color: hsl(241, 63%, 59%);
|
||||
--primary-color-highlight: hsl(241, 63%, 64%);
|
||||
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
|
||||
|
||||
--alternative-color: hsl(179, 94%, 29%);
|
||||
--alternative-color-dark: hsl(179, 94%, 22%);
|
||||
--alternative-color: hsl(179, 94%, 29%);
|
||||
--alternative-color-dark: hsl(179, 94%, 22%);
|
||||
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
|
||||
--error-color: hsl(0, 72%, 51%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
--error-color: hsl(0, 72%, 51%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-700);
|
||||
--secondary-text-color: var(--gray-500);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 63%, 55%);
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-700);
|
||||
--secondary-text-color: var(--gray-500);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 63%, 55%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
|
||||
|
||||
--icon-color: var(--gray-500);
|
||||
--icon-color: var(--gray-500);
|
||||
|
||||
--border-color: var(--gray-300);
|
||||
--secondary-border-color: var(--gray-200);
|
||||
--border-color: var(--gray-300);
|
||||
--secondary-border-color: var(--gray-200);
|
||||
|
||||
--body-color: #fff;
|
||||
--body-color-contrast: var(--gray-100);
|
||||
--body-color: #fff;
|
||||
--body-color-contrast: var(--gray-100);
|
||||
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC", "Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
|
||||
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
|
||||
monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
|
||||
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
|
||||
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
|
||||
var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
|
||||
var(--fallback-font-family);
|
||||
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||
|
||||
/* Unit sizes */
|
||||
--unit-o: 0.05rem;
|
||||
--unit-h: 0.1rem;
|
||||
--unit-1: 0.2rem;
|
||||
--unit-2: 0.4rem;
|
||||
--unit-3: 0.6rem;
|
||||
--unit-4: 0.8rem;
|
||||
--unit-5: 1rem;
|
||||
--unit-6: 1.2rem;
|
||||
--unit-7: 1.4rem;
|
||||
--unit-8: 1.6rem;
|
||||
--unit-9: 1.8rem;
|
||||
--unit-10: 2rem;
|
||||
--unit-12: 2.4rem;
|
||||
--unit-16: 3.2rem;
|
||||
/* Unit sizes */
|
||||
--unit-o: 0.05rem;
|
||||
--unit-h: 0.1rem;
|
||||
--unit-1: 0.2rem;
|
||||
--unit-2: 0.4rem;
|
||||
--unit-3: 0.6rem;
|
||||
--unit-4: 0.8rem;
|
||||
--unit-5: 1rem;
|
||||
--unit-6: 1.2rem;
|
||||
--unit-7: 1.4rem;
|
||||
--unit-8: 1.6rem;
|
||||
--unit-9: 1.8rem;
|
||||
--unit-10: 2rem;
|
||||
--unit-12: 2.4rem;
|
||||
--unit-16: 3.2rem;
|
||||
|
||||
/* Font sizes */
|
||||
--html-font-size: 20px;
|
||||
--html-line-height: 1.5;
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
--line-height: 1rem;
|
||||
/* Font sizes */
|
||||
--html-font-size: 20px;
|
||||
--html-line-height: 1.5;
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
--font-size-xl: 1rem;
|
||||
--line-height: 1rem;
|
||||
|
||||
/* Sizes */
|
||||
--layout-spacing: var(--unit-2);
|
||||
--layout-spacing-sm: var(--unit-1);
|
||||
--layout-spacing-lg: var(--unit-4);
|
||||
--border-radius: var(--unit-1);
|
||||
--border-radius-lg: var(--unit-2);
|
||||
--border-width: var(--unit-o);
|
||||
--border-width-lg: var(--unit-h);
|
||||
--control-size: var(--unit-8);
|
||||
--control-size-sm: var(--unit-6);
|
||||
--control-size-lg: var(--unit-9);
|
||||
--control-padding-x: var(--unit-2);
|
||||
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
|
||||
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
|
||||
--control-padding-y: calc((var(--control-size) - var(--line-height)) / 2 - var(--border-width));
|
||||
--control-padding-y-sm: calc((var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width));
|
||||
--control-padding-y-lg: calc((var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width));
|
||||
--control-icon-size: 0.8rem;
|
||||
/* Sizes */
|
||||
--layout-spacing: var(--unit-2);
|
||||
--layout-spacing-sm: var(--unit-1);
|
||||
--layout-spacing-lg: var(--unit-4);
|
||||
--border-radius: var(--unit-1);
|
||||
--border-radius-lg: var(--unit-2);
|
||||
--border-width: var(--unit-o);
|
||||
--border-width-lg: var(--unit-h);
|
||||
--control-size: var(--unit-8);
|
||||
--control-size-sm: var(--unit-6);
|
||||
--control-size-lg: var(--unit-9);
|
||||
--control-padding-x: var(--unit-2);
|
||||
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
|
||||
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
|
||||
--control-padding-y: calc(
|
||||
(var(--control-size) - var(--line-height)) / 2 - var(--border-width)
|
||||
);
|
||||
--control-padding-y-sm: calc(
|
||||
(var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width)
|
||||
);
|
||||
--control-padding-y-lg: calc(
|
||||
(var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width)
|
||||
);
|
||||
--control-icon-size: 0.8rem;
|
||||
|
||||
--control-width-xs: 180px;
|
||||
--control-width-sm: 320px;
|
||||
--control-width-md: 640px;
|
||||
--control-width-lg: 960px;
|
||||
--control-width-xl: 1280px;
|
||||
--control-width-xs: 180px;
|
||||
--control-width-sm: 320px;
|
||||
--control-width-md: 640px;
|
||||
--control-width-lg: 960px;
|
||||
--control-width-xl: 1280px;
|
||||
|
||||
/* Responsive breakpoints */
|
||||
--size-xs: 480px;
|
||||
--size-sm: 600px;
|
||||
--size-md: 840px;
|
||||
--size-lg: 960px;
|
||||
--size-xl: 1280px;
|
||||
--size-2x: 1440px;
|
||||
/* Responsive breakpoints */
|
||||
--size-xs: 480px;
|
||||
--size-sm: 600px;
|
||||
--size-md: 840px;
|
||||
--size-lg: 960px;
|
||||
--size-xl: 1280px;
|
||||
--size-2x: 1440px;
|
||||
|
||||
--responsive-breakpoint: var(--size-xs);
|
||||
--responsive-breakpoint: var(--size-xs);
|
||||
|
||||
/* Z-index */
|
||||
--zindex-0: 1;
|
||||
--zindex-1: 100;
|
||||
--zindex-2: 200;
|
||||
--zindex-3: 300;
|
||||
--zindex-4: 400;
|
||||
/* Z-index */
|
||||
--zindex-0: 1;
|
||||
--zindex-1: 100;
|
||||
--zindex-2: 200;
|
||||
--zindex-3: 300;
|
||||
--zindex-4: 400;
|
||||
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid var(--primary-color);
|
||||
--focus-outline-offset: 2px;
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid var(--primary-color);
|
||||
--focus-outline-offset: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
/* Shadows */
|
||||
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
@@ -4,17 +4,17 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
<div class="section-header mb-0">
|
||||
<h1 id="main-heading">Archived bookmarks</h1>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,23 +28,21 @@
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
@@ -5,151 +5,166 @@
|
||||
{% if bookmark_list.is_empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
<section aria-label="Bookmark list">
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
role="list" tabindex="-1"
|
||||
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.snapshot_url %}
|
||||
<a href="{{ bookmark_item.snapshot_url }}"
|
||||
title="{{ bookmark_item.snapshot_title }}"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
{% if not bookmark_list.is_preview %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not bookmark_list.is_preview %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if bookmark_list.show_preview_images %}
|
||||
{% if bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% else %}
|
||||
<div class="preview-image placeholder">
|
||||
<div class="img"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
@@ -22,6 +22,10 @@
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
{% if bookmark_list.snapshot_feature_enabled %}
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||
|
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% if not request.user_profile.hide_bundles %}
|
||||
<section aria-labelledby="bundles-heading">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn dropdown-toggle" aria-label="Bundles menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="M4 6l16 0"/>
|
||||
<path d="M4 12l16 0"/>
|
||||
<path d="M4 18l16 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
|
||||
</li>
|
||||
{% if bookmark_list.search.q %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
|
||||
bundle from search</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="bundle-menu">
|
||||
{% for bundle in bundles.bundles %}
|
||||
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
|
||||
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
@@ -1,12 +1,12 @@
|
||||
<div>
|
||||
{% if details.assets %}
|
||||
<div class="assets">
|
||||
<div class="item-list assets">
|
||||
{% for asset in details.assets %}
|
||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
||||
<div class="asset-icon {{ asset.icon_classes }}">
|
||||
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||
{% include 'bookmarks/details/asset_icon.html' %}
|
||||
</div>
|
||||
<div class="asset-text {{ asset.text_classes }}">
|
||||
<div class="list-item-text {{ asset.text_classes }}">
|
||||
<span class="truncate">
|
||||
{{ asset.display_name }}
|
||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||
@@ -16,9 +16,9 @@
|
||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<div class="list-item-actions">
|
||||
{% if asset.file %}
|
||||
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a>
|
||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||
{% endif %}
|
||||
{% if details.is_editable %}
|
||||
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
|
||||
@@ -33,12 +33,16 @@
|
||||
|
||||
{% if details.is_editable %}
|
||||
<div class="assets-actions">
|
||||
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
{% if details.snapshots_enabled %}
|
||||
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if details.uploads_enabled %}
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
{% endif %}
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@@ -14,7 +14,7 @@
|
||||
<span>{{ details.bookmark.url }}</span>
|
||||
</a>
|
||||
{% if details.latest_snapshot %}
|
||||
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}"
|
||||
<a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -40,14 +40,14 @@
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||
<div class="sections grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<div class="status col-2">
|
||||
<dt>Status</dt>
|
||||
<dd class="d-flex" style="gap: .8rem">
|
||||
<section class="status col-2">
|
||||
<h3>Status</h3>
|
||||
<div class="d-flex" style="gap: .8rem">
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||
@@ -71,44 +71,42 @@
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.show_files %}
|
||||
<div class="files col-2">
|
||||
<dt>Files</dt>
|
||||
<dd>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</dd>
|
||||
<section class="files col-2">
|
||||
<h3>Files</h3>
|
||||
<div>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if details.bookmark.tag_names %}
|
||||
<div class="tags col-1">
|
||||
<dt>Tags</dt>
|
||||
<dd>
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div>
|
||||
{% for tag_name in details.bookmark.tag_names %}
|
||||
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div class="date-added col-1">
|
||||
<dt>Date added</dt>
|
||||
<dd>
|
||||
<section class="date-added col-1">
|
||||
<h3>Date added</h3>
|
||||
<div>
|
||||
<span>{{ details.bookmark.date_added }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<div class="description col-2">
|
||||
<dt>Description</dt>
|
||||
<dd>{{ details.bookmark.resolved_description }}</dd>
|
||||
</div>
|
||||
</section>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<section class="description col-2">
|
||||
<h3>Description</h3>
|
||||
<div>{{ details.bookmark.resolved_description }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.notes %}
|
||||
<div class="notes col-2">
|
||||
<dt>Notes</dt>
|
||||
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
||||
</div>
|
||||
<section class="notes col-2">
|
||||
<h3>Notes</h3>
|
||||
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,21 +1,17 @@
|
||||
<div class="modal active bookmark-details"
|
||||
ld-details-modal>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
</a>
|
||||
<div class="modal-container">
|
||||
<div class="modal active bookmark-details" ld-details-modal
|
||||
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<button class="close">
|
||||
<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>
|
||||
</a>
|
||||
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
||||
<button class="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">
|
||||
@@ -28,7 +24,7 @@
|
||||
<div class="actions">
|
||||
<div class="left-actions">
|
||||
<a class="btn btn-wide"
|
||||
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">
|
||||
|
@@ -1,16 +1,21 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit bookmark - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
|
||||
</p>
|
||||
</div>
|
||||
|