mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
95 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
785fe32aaa | ||
![]() |
5559ad0070 | ||
![]() |
76c65566cf | ||
![]() |
c929e8f11c | ||
![]() |
3ae9cf0420 | ||
![]() |
b736464f3f | ||
![]() |
7572aa5bc9 | ||
![]() |
cb0301fd9e | ||
![]() |
b30486317d | ||
![]() |
1c6e5902db | ||
![]() |
20fe88dd57 | ||
![]() |
aad62f61c9 | ||
![]() |
79bf4b38c6 | ||
![]() |
5eadb3ede3 | ||
![]() |
36749c398b | ||
![]() |
190b5aeeca | ||
![]() |
1122d18e18 | ||
![]() |
0fe6304328 | ||
![]() |
7d4e65976f | ||
![]() |
749bc1ef63 | ||
![]() |
36a84276a2 | ||
![]() |
b72697b819 | ||
![]() |
d9362c9b9c | ||
![]() |
b0610db406 | ||
![]() |
af16a9e727 | ||
![]() |
d898c1be4d | ||
![]() |
0282220307 | ||
![]() |
bb243b382d | ||
![]() |
fbc97a3841 | ||
![]() |
380f5ed19c | ||
![]() |
b28352fb28 | ||
![]() |
695b0dc300 | ||
![]() |
fe40139838 | ||
![]() |
44b49a4cfe | ||
![]() |
469883a674 | ||
![]() |
fa5f78cf71 | ||
![]() |
e03f536925 | ||
![]() |
a92a35cfb8 | ||
![]() |
ff334e0888 | ||
![]() |
0f9ba57fef | ||
![]() |
b4376a9ff1 | ||
![]() |
87cd4061cb | ||
![]() |
e2415f652b | ||
![]() |
9cf5eb5ec0 | ||
![]() |
023a213ba6 | ||
![]() |
23d97db016 | ||
![]() |
0fb1bbd0e2 | ||
![]() |
5d2acca122 | ||
![]() |
0cbaf927e4 | ||
![]() |
0586983602 | ||
![]() |
9dc3521d5e | ||
![]() |
a1822e2091 | ||
![]() |
22ffecbb9d | ||
![]() |
d9096eacd6 | ||
![]() |
e50912df12 | ||
![]() |
393d688247 | ||
![]() |
6e38587174 | ||
![]() |
123c6fe02a | ||
![]() |
1b7731e506 | ||
![]() |
df9f0095cc | ||
![]() |
25470edb2c | ||
![]() |
22a1fc80ad | ||
![]() |
65f0eb2a04 | ||
![]() |
82f86bf537 | ||
![]() |
639629ddfe | ||
![]() |
2b342c0d56 | ||
![]() |
3ffec72d3e | ||
![]() |
edd958fff6 | ||
![]() |
2d22d6871e | ||
![]() |
5e8f5b2c58 | ||
![]() |
d5a83722de | ||
![]() |
5d8fdebb7c | ||
![]() |
f7bd6ccb31 | ||
![]() |
e4ee0171be | ||
![]() |
53d1f0c91b | ||
![]() |
a6f35119cd | ||
![]() |
68c163d943 | ||
![]() |
bb6c5ca29e | ||
![]() |
c919e79759 | ||
![]() |
8ff9b42a79 | ||
![]() |
4280ab40c6 | ||
![]() |
db1906942a | ||
![]() |
69877a32e5 | ||
![]() |
e5a9a772f0 | ||
![]() |
2f56d418cf | ||
![]() |
a4df586a8a | ||
![]() |
d9b7996e06 | ||
![]() |
92f62d3ded | ||
![]() |
9c48085829 | ||
![]() |
77e1525402 | ||
![]() |
9df80e01de | ||
![]() |
ec34cc523f | ||
![]() |
eb0b092d17 | ||
![]() |
39e8f03345 | ||
![]() |
d43b97e0c0 |
@@ -5,12 +5,12 @@
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
!/background-tasks-wrapper.sh
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
!/manage.py
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.mjs
|
||||
|
8
.github/workflows/main.yaml
vendored
8
.github/workflows/main.yaml
vendored
@@ -24,7 +24,9 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: pip install -r requirements.txt -r requirements.dev.txt
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
mkdir data
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
@@ -47,10 +49,10 @@ jobs:
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
mkdir data
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
python manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@@ -183,7 +183,7 @@ typings/
|
||||
### Custom
|
||||
# Rollup compilation output
|
||||
/bookmarks/static/bundle.js*
|
||||
# SASS compilation output
|
||||
# CSS compilation output
|
||||
/bookmarks/static/theme-*.css*
|
||||
# Collected static files for deployment
|
||||
/static
|
||||
@@ -191,3 +191,8 @@ typings/
|
||||
/tmp
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
166
CHANGELOG.md
166
CHANGELOG.md
@@ -1,5 +1,171 @@
|
||||
# Changelog
|
||||
|
||||
## v1.31.1 (30/08/2024)
|
||||
|
||||
### What's Changed
|
||||
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
|
||||
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
|
||||
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
|
||||
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
|
||||
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
|
||||
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
|
||||
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
|
||||
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
|
||||
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
|
||||
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
|
||||
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
|
||||
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
|
||||
|
||||
### New Contributors
|
||||
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
|
||||
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
|
||||
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
|
||||
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
|
||||
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
|
||||
|
||||
---
|
||||
|
||||
## v1.31.0 (16/06/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
|
||||
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
|
||||
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
|
||||
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
|
||||
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
|
||||
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
|
||||
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
|
||||
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
|
||||
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
|
||||
|
||||
### New Contributors
|
||||
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
|
||||
|
||||
---
|
||||
|
||||
## v1.30.0 (20/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
|
||||
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
|
||||
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
|
||||
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
|
||||
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
|
||||
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
|
||||
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
|
||||
|
||||
### New Contributors
|
||||
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
|
||||
|
||||
---
|
||||
|
||||
## v1.29.0 (14/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Remove ads and cookie banners from HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/695
|
||||
* Add button for creating missing HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/696
|
||||
* Refresh file list when there are queued snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/697
|
||||
* Bump idna from 3.6 to 3.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/694
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.28.0...v1.29.0
|
||||
|
||||
---
|
||||
|
||||
## v1.28.0 (09/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684
|
||||
* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686
|
||||
* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687
|
||||
* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688
|
||||
* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691
|
||||
* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689
|
||||
|
||||
### New Contributors
|
||||
* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684
|
||||
* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0
|
||||
|
||||
---
|
||||
|
||||
## v1.27.1 (07/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix HTML snapshot errors related to single-file-cli by @sissbruecker in https://github.com/sissbruecker/linkding/pull/683
|
||||
* Replace django-background-tasks with huey by @sissbruecker in https://github.com/sissbruecker/linkding/pull/657
|
||||
* Add Authelia OIDC example to docs by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/675
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.0...v1.27.1
|
||||
|
||||
---
|
||||
|
||||
## v1.27.0 (01/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Archive snapshots of websites locally by @sissbruecker in https://github.com/sissbruecker/linkding/pull/672
|
||||
* Add Railway hosting option by @tianheg in https://github.com/sissbruecker/linkding/pull/661
|
||||
* Add how to for increasing the font size by @sissbruecker in https://github.com/sissbruecker/linkding/pull/667
|
||||
|
||||
### New Contributors
|
||||
* @tianheg made their first contribution in https://github.com/sissbruecker/linkding/pull/661
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.26.0...v1.27.0
|
||||
|
||||
---
|
||||
|
||||
## v1.26.0 (30/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add option for showing bookmark description as separate block by @sissbruecker in https://github.com/sissbruecker/linkding/pull/663
|
||||
* Add bookmark details view by @sissbruecker in https://github.com/sissbruecker/linkding/pull/665
|
||||
* Make bookmark list actions configurable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/666
|
||||
* Bump black from 24.1.1 to 24.3.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/662
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.25.0...v1.26.0
|
||||
|
||||
---
|
||||
|
||||
## v1.25.0 (18/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Improve PWA capabilities by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/630
|
||||
* build improvements by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/649
|
||||
* Add support for oidc by @Nighmared in https://github.com/sissbruecker/linkding/pull/389
|
||||
* Add option for custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/652
|
||||
* Update backup location to safe directory by @bphenriques in https://github.com/sissbruecker/linkding/pull/653
|
||||
* Include web archive link in /api/bookmarks/ by @sissbruecker in https://github.com/sissbruecker/linkding/pull/655
|
||||
* Add RSS feeds for shared bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/656
|
||||
* Bump django from 5.0.2 to 5.0.3 by @dependabot in https://github.com/sissbruecker/linkding/pull/658
|
||||
|
||||
### New Contributors
|
||||
* @hugo-vrijswijk made their first contribution in https://github.com/sissbruecker/linkding/pull/630
|
||||
* @Nighmared made their first contribution in https://github.com/sissbruecker/linkding/pull/389
|
||||
* @bphenriques made their first contribution in https://github.com/sissbruecker/linkding/pull/653
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.2...v1.25.0
|
||||
|
||||
---
|
||||
|
||||
## v1.24.2 (16/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix logout button by @sissbruecker in https://github.com/sissbruecker/linkding/pull/648
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.1...v1.24.2
|
||||
|
||||
---
|
||||
|
||||
## v1.24.1 (16/03/2024)
|
||||
|
||||
### What's Changed
|
||||
|
2
Makefile
2
Makefile
@@ -7,7 +7,7 @@ tasks:
|
||||
python manage.py process_tasks
|
||||
|
||||
test:
|
||||
pytest
|
||||
pytest -n auto
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
|
69
README.md
69
README.md
@@ -33,22 +33,19 @@ The name comes from:
|
||||
**Feature Overview:**
|
||||
- Clean UI optimized for readability
|
||||
- Organize bookmarks with tags
|
||||
- Add notes using Markdown
|
||||
- Read it later functionality
|
||||
- Share bookmarks with other users
|
||||
- Bulk editing
|
||||
- Bulk editing, Markdown notes, read it later functionality
|
||||
- Share bookmarks with other users or guests
|
||||
- Automatically provides titles, descriptions and icons of bookmarked websites
|
||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
||||
- Automatically archive websites, either as local HTML file or on Internet Archive
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Installable as a Progressive Web App (PWA)
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- Light and dark themes
|
||||
- SSO support via OIDC or authentication proxies
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
||||
**Demo:** https://demo.linkding.link/
|
||||
|
||||
**Screenshot:**
|
||||
|
||||
@@ -62,27 +59,45 @@ The Docker image is compatible with ARM platforms, so it can be run on a Raspber
|
||||
linkding uses an SQLite database by default.
|
||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>🧪 Alpine-based image</summary>
|
||||
|
||||
The default Docker image (`latest` tag) is based on a slim variant of Debian Linux.
|
||||
Alternatively, there is an image based on Alpine Linux (`latest-alpine` tag) which has a smaller size, resulting in a smaller download and less disk space required.
|
||||
The Alpine image is currently about 45 MB in compressed size, compared to about 130 MB for the Debian image.
|
||||
|
||||
To use it, replace the `latest` tag with `latest-alpine`, either in the CLI command below when using Docker, or in the `docker-compose.yml` file when using docker-compose.
|
||||
|
||||
> [!WARNING]
|
||||
> The image is currently considered experimental in order to gather feedback and iron out any issues.
|
||||
> Only use it if you are comfortable running experimental software or want to help out with testing.
|
||||
> While there should be no issues with creating new installations, there might be issues when migrating existing installations.
|
||||
> If you plan to migrate your existing installation, make sure to create proper [backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) first.
|
||||
|
||||
</details>
|
||||
|
||||
### Using Docker
|
||||
|
||||
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
||||
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
|
||||
```
|
||||
@@ -184,6 +199,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
|
||||
- [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)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -221,6 +237,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [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
|
||||
|
BIN
assets/logo-inset.afdesign
Normal file
BIN
assets/logo-inset.afdesign
Normal file
Binary file not shown.
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
1
assets/logo.svg
Normal file
1
assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
After Width: | Height: | Size: 688 B |
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
|
||||
|
||||
python manage.py clean_tasks
|
||||
exec python manage.py process_tasks
|
@@ -1,22 +1,96 @@
|
||||
from background_task.admin import TaskAdmin, CompletedTaskAdmin
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Count, QuerySet
|
||||
from django.shortcuts import render
|
||||
from django.urls import path
|
||||
from django.utils.translation import ngettext, gettext
|
||||
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, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
# Custom paginator to paginate through Huey tasks
|
||||
class TaskPaginator(Paginator):
|
||||
def __init__(self):
|
||||
super().__init__(self, 100)
|
||||
self.task_count = huey.storage.queue_size()
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
return self.task_count
|
||||
|
||||
def page(self, number):
|
||||
limit = self.per_page
|
||||
offset = (number - 1) * self.per_page
|
||||
return self._get_page(
|
||||
self.enqueued_items(limit, offset),
|
||||
number,
|
||||
self,
|
||||
)
|
||||
|
||||
# Copied from Huey's SqliteStorage with some modifications to allow pagination
|
||||
def enqueued_items(self, limit, offset):
|
||||
to_bytes = lambda b: bytes(b) if not isinstance(b, bytes) else b
|
||||
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
|
||||
params = (huey.storage.name, limit, offset)
|
||||
|
||||
serialized_tasks = [
|
||||
to_bytes(i) for i, in huey.storage.sql(sql, params, results=True)
|
||||
]
|
||||
return [huey.deserialize_task(task) for task in serialized_tasks]
|
||||
|
||||
|
||||
# Custom view to display Huey tasks in the admin
|
||||
def background_task_view(request):
|
||||
page_number = int(request.GET.get("p", 1))
|
||||
paginator = TaskPaginator()
|
||||
page = paginator.get_page(page_number)
|
||||
page_range = paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=2)
|
||||
context = {
|
||||
**linkding_admin_site.each_context(request),
|
||||
"title": "Background tasks",
|
||||
"page": page,
|
||||
"page_range": page_range,
|
||||
"tasks": page.object_list,
|
||||
}
|
||||
return render(request, "admin/background_tasks.html", context)
|
||||
|
||||
|
||||
class LinkdingAdminSite(AdminSite):
|
||||
site_header = "linkding administration"
|
||||
site_title = "linkding Admin"
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path("tasks/", background_task_view, name="background_tasks"),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
app_list = super().get_app_list(request, app_label)
|
||||
app_list += [
|
||||
{
|
||||
"name": "Huey",
|
||||
"app_label": "huey_app",
|
||||
"models": [
|
||||
{
|
||||
"name": "Queued tasks",
|
||||
"object_name": "background_tasks",
|
||||
"admin_url": "/admin/tasks/",
|
||||
"view_only": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
return app_list
|
||||
|
||||
|
||||
class AdminBookmark(admin.ModelAdmin):
|
||||
list_display = ("resolved_title", "url", "is_archived", "owner", "date_added")
|
||||
@@ -125,6 +199,19 @@ class AdminBookmark(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
@admin.display(description="Display Name")
|
||||
def custom_display_name(self, obj):
|
||||
return str(obj)
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
|
||||
|
||||
class AdminTag(admin.ModelAdmin):
|
||||
list_display = ("name", "bookmarks_count", "owner", "date_added")
|
||||
search_fields = ("name", "owner__username")
|
||||
@@ -200,10 +287,9 @@ class AdminFeedToken(admin.ModelAdmin):
|
||||
|
||||
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(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||
linkding_admin_site.register(Task, TaskAdmin)
|
||||
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -11,6 +13,7 @@ from bookmarks.api.serializers import (
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
@@ -18,6 +21,8 @@ from bookmarks.services.bookmarks import (
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
@@ -51,7 +56,7 @@ class BookmarkViewSet(
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
return {"request": self.request, "user": self.request.user}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
@@ -59,8 +64,8 @@ class BookmarkViewSet(
|
||||
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_class()
|
||||
data = serializer(page, many=True).data
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@@ -72,8 +77,8 @@ class BookmarkViewSet(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
@@ -99,13 +104,32 @@ class BookmarkViewSet(
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url, bookmark.website_title, bookmark.website_description
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
auto_tags = []
|
||||
if profile.auto_tagging_rules:
|
||||
try:
|
||||
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}",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()},
|
||||
{
|
||||
"bookmark": existing_bookmark_data,
|
||||
"metadata": metadata.to_dict(),
|
||||
"auto_tags": auto_tags,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
@@ -31,6 +32,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"shared",
|
||||
@@ -42,6 +45,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
@@ -56,6 +61,24 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
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=[])
|
||||
favicon_url = serializers.SerializerMethodField()
|
||||
preview_image_url = serializers.SerializerMethodField()
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
return None
|
||||
request = self.context.get("request")
|
||||
favicon_file_path = static(obj.favicon_file)
|
||||
favicon_url = request.build_absolute_uri(favicon_file_path)
|
||||
return favicon_url
|
||||
|
||||
def get_preview_image_url(self, obj: Bookmark):
|
||||
if not obj.preview_image_file:
|
||||
return None
|
||||
request = self.context.get("request")
|
||||
preview_image_file_path = static(obj.preview_image_file)
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
|
@@ -18,19 +18,5 @@ def toasts(request):
|
||||
}
|
||||
|
||||
|
||||
def public_shares(request):
|
||||
# Only check for public shares for anonymous users
|
||||
if not request.user.is_authenticated:
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
None, request.user_profile, BookmarkSearch(), True
|
||||
)
|
||||
has_public_shares = query_set.count() > 0
|
||||
return {
|
||||
"has_public_shares": has_public_shares,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def app_version(request):
|
||||
return {"app_version": utils.app_version}
|
||||
|
176
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
176
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_details(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
title = details_modal.locator("h2")
|
||||
expect(title).to_have_text(bookmark.title)
|
||||
|
||||
def test_close_details(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# close with close button
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
details_modal.locator("button.close").click()
|
||||
expect(details_modal).to_be_hidden()
|
||||
|
||||
# close with backdrop
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
overlay = details_modal.locator(".modal-overlay")
|
||||
overlay.click(position={"x": 0, "y": 0})
|
||||
expect(details_modal).to_be_hidden()
|
||||
|
||||
# close with escape
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
self.page.keyboard.press("Escape")
|
||||
expect(details_modal).to_be_hidden()
|
||||
|
||||
def test_toggle_archived(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# archive
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
details_modal.get_by_text("Archived", exact=False).click()
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
# unarchive
|
||||
url = reverse("bookmarks:archived")
|
||||
self.page.goto(self.live_server_url + url)
|
||||
self.resetReloads()
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
details_modal.get_by_text("Archived", exact=False).click()
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_toggle_unread(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# mark as unread
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
details_modal.get_by_text("Unread").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Unread")).to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
# mark as read
|
||||
details_modal.get_by_text("Unread").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_toggle_shared(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# share bookmark
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
details_modal.get_by_text("Shared").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Shared")).to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
# unshare bookmark
|
||||
details_modal.get_by_text("Shared").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_edit_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
# Navigate to edit page
|
||||
with self.page.expect_navigation():
|
||||
details_modal.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
self.page.get_by_text("Nevermind").click()
|
||||
|
||||
def test_delete(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
# Delete bookmark, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
details_modal.get_by_text("Delete...").click()
|
||||
details_modal.get_by_text("Confirm").click()
|
||||
|
||||
# verify bookmark is deleted
|
||||
self.locate_bookmark(bookmark.title)
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_snapshot_remove_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
asset_list = details_modal.locator(".assets")
|
||||
|
||||
# No snapshots initially
|
||||
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
|
||||
expect(snapshot).not_to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
|
||||
self.assertReloads(0)
|
||||
|
||||
# Has new snapshots
|
||||
expect(snapshot).to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
asset_list.get_by_text("Remove", exact=False).click()
|
||||
asset_list.get_by_text("Confirm", exact=False).click()
|
||||
|
||||
# Snapshot is removed
|
||||
expect(snapshot).not_to_be_visible()
|
||||
self.assertReloads(0)
|
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_edit_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||
|
||||
# Navigate to edit page
|
||||
with self.page.expect_navigation():
|
||||
self.page.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(
|
||||
url=self.live_server_url
|
||||
+ reverse("bookmarks:details", args=[bookmark.id])
|
||||
):
|
||||
self.page.get_by_text("Nevermind").click()
|
||||
|
||||
def test_delete_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||
|
||||
# Trigger delete, verify return url
|
||||
# Should probably return to last bookmark list page, but for now just returns to index
|
||||
with self.page.expect_navigation(
|
||||
url=self.live_server_url + reverse("bookmarks:index")
|
||||
):
|
||||
self.page.get_by_text("Delete...").click()
|
||||
self.page.get_by_text("Confirm").click()
|
@@ -85,3 +85,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
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()
|
||||
|
@@ -39,6 +39,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -46,6 +47,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
0,
|
||||
@@ -74,6 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -81,6 +85,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -109,6 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index") + "?q=foo", p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -116,6 +123,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -144,6 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived") + "?q=foo", p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -151,6 +161,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -182,7 +194,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
self.assertEqual(6, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
@@ -252,13 +264,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
# Hide select across by toggling a single bookmark
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
@@ -269,14 +281,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("bookmarks:index")
|
||||
page = self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
# Get reference for bookmark list
|
||||
bookmark_list = page.locator("ul[ld-bookmark-list]")
|
||||
|
||||
# Execute bulk action
|
||||
self.select_bulk_action("Mark as unread")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -286,7 +297,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
self.assertEqual(31, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
@@ -302,6 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
@@ -312,6 +324,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
@@ -169,7 +169,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Archive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -187,7 +187,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -230,7 +230,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Unarchive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -248,7 +248,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
|
@@ -2,6 +2,7 @@ from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
|
||||
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
@@ -39,3 +40,49 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
|
||||
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.bookmark_description_display = (
|
||||
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
|
||||
)
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
max_lines = page.get_by_label("Bookmark description max lines")
|
||||
expect(max_lines).to_be_hidden()
|
||||
|
||||
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.bookmark_description_display = (
|
||||
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
|
||||
)
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
max_lines = page.get_by_label("Bookmark description max lines")
|
||||
expect(max_lines).to_be_visible()
|
||||
|
||||
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
max_lines = page.get_by_label("Bookmark description max lines")
|
||||
expect(max_lines).to_be_hidden()
|
||||
|
||||
display = page.get_by_label("Bookmark description", exact=True)
|
||||
display.select_option("separate")
|
||||
expect(max_lines).to_be_visible()
|
||||
|
||||
display.select_option("inline")
|
||||
expect(max_lines).to_be_hidden()
|
||||
|
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect, Locator
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_modal_close_modal(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
)
|
||||
modal_trigger.click()
|
||||
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
|
||||
# close with backdrop
|
||||
backdrop = modal.locator(".modal-overlay")
|
||||
backdrop.click(position={"x": 0, "y": 0})
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
def test_select_tag(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
)
|
||||
modal_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
modal = page.locator(".modal")
|
||||
unselected_tags = modal.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
# select tag
|
||||
unselected_tags.get_by_text("cooking").click()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
|
||||
# verify tag is selected, other tag is not visible anymore
|
||||
selected_tags = modal.locator(".selected-tags")
|
||||
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||
|
||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()
|
@@ -1,5 +1,6 @@
|
||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
@@ -38,15 +39,33 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
def assertReloads(self, count: int):
|
||||
self.assertEqual(self.num_loads, count)
|
||||
|
||||
def resetReloads(self):
|
||||
self.num_loads = 0
|
||||
|
||||
def locate_bookmark_list(self):
|
||||
return self.page.locator("ul[ld-bookmark-list]")
|
||||
|
||||
def locate_bookmark(self, title: str):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
def locate_details_modal(self):
|
||||
return self.page.locator(".modal.bookmark-details")
|
||||
|
||||
def open_details_modal(self, bookmark):
|
||||
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
|
||||
details_button.click()
|
||||
|
||||
details_modal = self.locate_details_modal()
|
||||
expect(details_modal).to_be_visible()
|
||||
|
||||
return details_modal
|
||||
|
||||
def locate_bulk_edit_bar(self):
|
||||
return self.page.locator(".bulk-edit-bar")
|
||||
|
||||
def locate_bulk_edit_select_all(self):
|
||||
return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]")
|
||||
return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
|
||||
|
||||
def locate_bulk_edit_select_across(self):
|
||||
return self.locate_bulk_edit_bar().locator("label.select-across")
|
||||
|
@@ -2,7 +2,8 @@ import unicodedata
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import QuerySet, prefetch_related_objects
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
|
||||
@dataclass
|
||||
class FeedContext:
|
||||
request: HttpRequest
|
||||
feed_token: FeedToken | None
|
||||
query_set: QuerySet[Bookmark]
|
||||
|
||||
@@ -26,13 +28,27 @@ def sanitize(text: str):
|
||||
|
||||
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
query_set = queries.query_bookmarks(
|
||||
feed_token.user, feed_token.user.profile, search
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
)
|
||||
return FeedContext(feed_token, query_set)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
raise NotImplementedError
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
limit = context.request.GET.get("limit", 100)
|
||||
if limit:
|
||||
data = context.query_set[: int(limit)]
|
||||
else:
|
||||
data = list(context.query_set)
|
||||
prefetch_related_objects(data, "tags")
|
||||
return data
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
return sanitize(item.resolved_title)
|
||||
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
|
||||
def item_pubdate(self, item: Bookmark):
|
||||
return item.date_added
|
||||
|
||||
def item_categories(self, item: Bookmark):
|
||||
return item.tag_names
|
||||
|
||||
|
||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "All bookmarks"
|
||||
description = "All bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
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])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Unread bookmarks"
|
||||
description = "All unread bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_bookmarks(
|
||||
feed_token.user, feed_token.user.profile, search
|
||||
).filter(unread=True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set.filter(unread=True)
|
||||
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Shared bookmarks"
|
||||
description = "All shared bookmarks"
|
||||
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_shared_bookmarks(
|
||||
None, feed_token.user.profile, search, False
|
||||
)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Public shared bookmarks"
|
||||
description = "All public shared bookmarks"
|
||||
|
||||
def get_object(self, request):
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
default_profile = UserProfile()
|
||||
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
||||
return FeedContext(None, query_set)
|
||||
return super().get_object(request, None)
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.public_shared")
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
@@ -1,63 +1,8 @@
|
||||
import { registerBehavior, swap } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BookmarkPage {
|
||||
class BookmarkItem extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.form = element.querySelector("form.bookmark-actions");
|
||||
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
|
||||
|
||||
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
||||
this.tagCloud = element.querySelector(".tag-cloud-container");
|
||||
}
|
||||
|
||||
async onFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = this.form.action;
|
||||
const formData = new FormData(this.form);
|
||||
formData.append(event.submitter.name, event.submitter.value);
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual", // ignore redirect
|
||||
});
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const query = window.location.search;
|
||||
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
|
||||
const tagsUrl = this.element.getAttribute("tags-url");
|
||||
Promise.all([
|
||||
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
|
||||
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
|
||||
]).then(([bookmarkListHtml, tagCloudHtml]) => {
|
||||
swap(this.bookmarkList, bookmarkListHtml);
|
||||
swap(this.tagCloud, tagCloudHtml);
|
||||
|
||||
// Dispatch list updated event
|
||||
const listElement = this.bookmarkList.querySelector(
|
||||
"ul[data-bookmarks-total]",
|
||||
);
|
||||
const bookmarksTotal =
|
||||
(listElement && listElement.dataset.bookmarksTotal) || 0;
|
||||
|
||||
this.bookmarkList.dispatchEvent(
|
||||
new CustomEvent("bookmark-list-updated", {
|
||||
bubbles: true,
|
||||
detail: { bookmarksTotal },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bookmark-page", BookmarkPage);
|
||||
|
||||
class BookmarkItem {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
super(element);
|
||||
|
||||
// Toggle notes
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
@@ -68,9 +13,11 @@ class BookmarkItem {
|
||||
// Add tooltip to title if it is truncated
|
||||
const titleAnchor = element.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
|
@@ -1,46 +1,60 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BulkEdit {
|
||||
class BulkEdit extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
super(element);
|
||||
|
||||
this.active = false;
|
||||
this.actionSelect = element.querySelector("select[name='bulk_action']");
|
||||
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = element.querySelector("label.select-across");
|
||||
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-active",
|
||||
this.onToggleActive.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-all",
|
||||
this.onToggleAll.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-bookmark",
|
||||
this.onToggleBookmark.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bookmark-list-updated",
|
||||
this.onListUpdated.bind(this),
|
||||
);
|
||||
this.onToggleActive = this.onToggleActive.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.actionSelect.addEventListener(
|
||||
"change",
|
||||
this.onActionSelected.bind(this),
|
||||
);
|
||||
this.init();
|
||||
// Reset when bookmarks are refreshed
|
||||
document.addEventListener("refresh-bookmark-list-done", () => this.init());
|
||||
}
|
||||
|
||||
get allCheckbox() {
|
||||
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
|
||||
}
|
||||
init() {
|
||||
// Update elements
|
||||
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.element.querySelector(
|
||||
"select[name='bulk_action']",
|
||||
);
|
||||
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = this.element.querySelector("label.select-across");
|
||||
this.allCheckbox = this.element.querySelector(
|
||||
".bulk-edit-checkbox.all input",
|
||||
);
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
get bookmarkCheckboxes() {
|
||||
return [
|
||||
...this.element.querySelectorAll(
|
||||
"[ld-bulk-edit-checkbox]:not([all]) input",
|
||||
),
|
||||
];
|
||||
// Remove previous listeners if elements are the same
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
|
||||
// Add new listeners
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
onToggleActive() {
|
||||
@@ -81,16 +95,6 @@ class BulkEdit {
|
||||
}
|
||||
}
|
||||
|
||||
onListUpdated(event) {
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const total = event.detail.bookmarksTotal;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
@@ -109,33 +113,4 @@ class BulkEdit {
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditActiveToggle {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.element.dispatchEvent(
|
||||
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditCheckbox {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
element.addEventListener("change", this.onChange.bind(this));
|
||||
}
|
||||
|
||||
onChange() {
|
||||
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
|
||||
this.element.dispatchEvent(
|
||||
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bulk-edit", BulkEdit);
|
||||
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
|
||||
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);
|
||||
|
@@ -1,25 +1,29 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ConfirmButtonBehavior {
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
const button = element;
|
||||
button.dataset.type = button.type;
|
||||
button.dataset.name = button.name;
|
||||
button.dataset.value = button.value;
|
||||
button.removeAttribute("type");
|
||||
button.removeAttribute("name");
|
||||
button.removeAttribute("value");
|
||||
button.addEventListener("click", this.onClick.bind(this));
|
||||
this.button = button;
|
||||
super(element);
|
||||
element.dataset.type = element.type;
|
||||
element.dataset.name = element.name;
|
||||
element.dataset.value = element.value;
|
||||
element.removeAttribute("type");
|
||||
element.removeAttribute("name");
|
||||
element.removeAttribute("value");
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Behavior.interacting = false;
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
Behavior.interacting = true;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.button.getAttribute("confirm-icon");
|
||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
@@ -31,38 +35,43 @@ class ConfirmButtonBehavior {
|
||||
container.append(iconElement);
|
||||
}
|
||||
|
||||
const question = this.button.getAttribute("confirm-question");
|
||||
const question = this.element.getAttribute("ld-confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const cancelButton = document.createElement(this.button.nodeName);
|
||||
const buttonClasses = Array.from(this.element.classList.values())
|
||||
.filter((cls) => cls.startsWith("btn"))
|
||||
.join(" ");
|
||||
|
||||
const cancelButton = document.createElement(this.element.nodeName);
|
||||
cancelButton.type = "button";
|
||||
cancelButton.innerText = question ? "No" : "Cancel";
|
||||
cancelButton.className = "btn btn-link btn-sm mr-1";
|
||||
cancelButton.className = `${buttonClasses} mr-1`;
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.button.nodeName);
|
||||
confirmButton.type = this.button.dataset.type;
|
||||
confirmButton.name = this.button.dataset.name;
|
||||
confirmButton.value = this.button.dataset.value;
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.dataset.type;
|
||||
confirmButton.name = this.element.dataset.name;
|
||||
confirmButton.value = this.element.dataset.value;
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = "btn btn-link btn-sm";
|
||||
confirmButton.className = buttonClasses;
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
container.append(cancelButton, confirmButton);
|
||||
this.container = container;
|
||||
|
||||
this.button.before(container);
|
||||
this.button.classList.add("d-none");
|
||||
this.element.before(container);
|
||||
this.element.classList.add("d-none");
|
||||
}
|
||||
|
||||
reset() {
|
||||
setTimeout(() => {
|
||||
Behavior.interacting = false;
|
||||
this.container.remove();
|
||||
this.button.classList.remove("d-none");
|
||||
this.element.classList.remove("d-none");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class DropdownBehavior {
|
||||
class DropdownBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
|
||||
|
48
bookmarks/frontend/behaviors/fetch.js
Normal file
48
bookmarks/frontend/behaviors/fetch.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
||||
|
||||
class FetchBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const eventName = element.getAttribute("ld-on");
|
||||
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
||||
|
||||
this.onFetch = this.onFetch.bind(this);
|
||||
this.onInterval = this.onInterval.bind(this);
|
||||
|
||||
element.addEventListener(eventName, this.onFetch);
|
||||
if (interval) {
|
||||
this.intervalId = setInterval(this.onInterval, interval);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
async onFetch(maybeEvent) {
|
||||
if (maybeEvent) {
|
||||
maybeEvent.preventDefault();
|
||||
}
|
||||
const url = this.element.getAttribute("ld-fetch");
|
||||
const html = await fetch(url).then((response) => response.text());
|
||||
|
||||
const target = this.element.getAttribute("ld-target");
|
||||
const select = this.element.getAttribute("ld-select");
|
||||
swap(this.element, html, { target, select });
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
fireEvents(events);
|
||||
}
|
||||
|
||||
onInterval() {
|
||||
if (Behavior.interacting) {
|
||||
return;
|
||||
}
|
||||
this.onFetch();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-fetch", FetchBehavior);
|
64
bookmarks/frontend/behaviors/form.js
Normal file
64
bookmarks/frontend/behaviors/form.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Behavior, fireEvents, registerBehavior } from "./index";
|
||||
|
||||
class FormBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("submit", this.onSubmit.bind(this));
|
||||
}
|
||||
|
||||
async onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = this.element.action;
|
||||
const formData = new FormData(this.element);
|
||||
if (event.submitter) {
|
||||
formData.append(event.submitter.name, event.submitter.value);
|
||||
}
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual", // ignore redirect
|
||||
});
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
if (fireEvents) {
|
||||
fireEvents(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("change", () => {
|
||||
const form = element.closest("form");
|
||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const fileInput = element.nextElementSibling;
|
||||
|
||||
element.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const form = fileInput.closest("form");
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
event.submitter = element;
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form", FormBehavior);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
@@ -1,7 +1,9 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class GlobalShortcuts extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
class GlobalShortcuts {
|
||||
constructor() {
|
||||
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,39 @@
|
||||
const behaviorRegistry = {};
|
||||
const debug = false;
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && !node.isConnected) {
|
||||
destroyBehaviors(node);
|
||||
}
|
||||
});
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.isConnected) {
|
||||
applyBehaviors(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("turbo:load", () => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
@@ -12,7 +47,14 @@ export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const behavior = behaviorRegistry[behaviorName];
|
||||
const elements = container.querySelectorAll(`[${behaviorName}]`);
|
||||
const elements = Array.from(
|
||||
container.querySelectorAll(`[${behaviorName}]`),
|
||||
);
|
||||
|
||||
// Include the container element if it has the behavior
|
||||
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
|
||||
elements.push(container);
|
||||
}
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.__behaviors = element.__behaviors || [];
|
||||
@@ -26,11 +68,82 @@ export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
||||
const behaviorInstance = new behavior(element);
|
||||
element.__behaviors.push(behaviorInstance);
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html) {
|
||||
element.innerHTML = html;
|
||||
applyBehaviors(element);
|
||||
export function destroyBehaviors(element) {
|
||||
const behaviorNames = Object.keys(behaviorRegistry);
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
|
||||
elements.push(element);
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (!element.__behaviors) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.__behaviors.forEach((behavior) => {
|
||||
behavior.destroy();
|
||||
if (debug) {
|
||||
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
|
||||
}
|
||||
});
|
||||
delete element.__behaviors;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html, options) {
|
||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
let targetElement = element;
|
||||
let strategy = "innerHTML";
|
||||
if (options.target) {
|
||||
const parts = options.target.split("|");
|
||||
targetElement =
|
||||
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
||||
strategy = parts[1] || "innerHTML";
|
||||
}
|
||||
|
||||
let contents = Array.from(dom.body.children);
|
||||
if (options.select) {
|
||||
contents = Array.from(dom.querySelectorAll(options.select));
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case "append":
|
||||
targetElement.append(...contents);
|
||||
break;
|
||||
case "outerHTML":
|
||||
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
||||
break;
|
||||
case "innerHTML":
|
||||
default:
|
||||
Array.from(targetElement.children).forEach((child) => {
|
||||
child.remove();
|
||||
});
|
||||
targetElement.append(...contents);
|
||||
}
|
||||
}
|
||||
|
||||
export function fireEvents(events) {
|
||||
if (!events) {
|
||||
return;
|
||||
}
|
||||
events.split(",").forEach((eventName) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll(`[ld-on='${eventName}']`),
|
||||
);
|
||||
targets.push(document);
|
||||
targets.forEach((target) => {
|
||||
target.dispatchEvent(new CustomEvent(eventName));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -1,64 +1,50 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ModalBehavior {
|
||||
class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
const toggle = element;
|
||||
toggle.addEventListener("click", this.onToggleClick.bind(this));
|
||||
this.toggle = toggle;
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
const modalOverlay = element.querySelector(".modal-overlay");
|
||||
const closeButton = element.querySelector("button.close");
|
||||
modalOverlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onToggleClick() {
|
||||
const contentSelector = this.toggle.getAttribute("modal-content");
|
||||
const content = document.querySelector(contentSelector);
|
||||
if (!content) {
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create modal
|
||||
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 d-flex justify-between align-center">
|
||||
<div class="modal-title h5">Tags</div>
|
||||
<button class="btn btn-link 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>
|
||||
`;
|
||||
|
||||
// Teleport content element
|
||||
const contentOwner = content.parentElement;
|
||||
const contentContainer = modal.querySelector(".content");
|
||||
contentContainer.append(content);
|
||||
this.content = content;
|
||||
this.contentOwner = contentOwner;
|
||||
|
||||
// Register close handlers
|
||||
const modalOverlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".btn.close");
|
||||
modalOverlay.addEventListener("click", this.onClose.bind(this));
|
||||
closeButton.addEventListener("click", this.onClose.bind(this));
|
||||
|
||||
document.body.append(modal);
|
||||
this.modal = modal;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
// Teleport content back
|
||||
this.contentOwner.append(this.content);
|
||||
|
||||
// Remove modal
|
||||
this.modal.remove();
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener("animationend", (event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import { ApiClient } from "../api";
|
||||
|
||||
class TagAutocomplete {
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const wrapper = document.createElement("div");
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
const apiClient = new ApiClient(apiBaseUrl);
|
||||
|
@@ -150,19 +150,31 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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.small .form-autocomplete-input {
|
||||
height: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,10 +1,13 @@
|
||||
import './behaviors/bookmark-page';
|
||||
import './behaviors/bulk-edit';
|
||||
import './behaviors/confirm-button';
|
||||
import './behaviors/dropdown';
|
||||
import './behaviors/modal';
|
||||
import './behaviors/global-shortcuts';
|
||||
import './behaviors/tag-autocomplete';
|
||||
export { default as TagAutoComplete } from './components/TagAutocomplete.svelte';
|
||||
export { default as SearchAutoComplete } from './components/SearchAutoComplete.svelte';
|
||||
export { ApiClient } from './api';
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/fetch";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { ApiClient } from "./api";
|
||||
|
@@ -24,3 +24,8 @@ class Command(BaseCommand):
|
||||
source_db.close()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {destination}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"This backup method is deprecated and may be removed in the future. Please use the full_backup command instead, which creates backup zip file with all contents of the data folder."
|
||||
)
|
||||
)
|
||||
|
@@ -1,15 +0,0 @@
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Remove task locks and clear completed task history"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Remove task locks
|
||||
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
|
||||
# even though no process is working on them, and would prevent the task processor from picking the next task in
|
||||
# the queue
|
||||
Task.objects.all().update(locked_by=None, locked_at=None)
|
||||
# Clear task history to prevent them from bloating the DB
|
||||
CompletedTask.objects.all().delete()
|
75
bookmarks/management/commands/full_backup.py
Normal file
75
bookmarks/management/commands/full_backup.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates a backup of the linkding data folder"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("backup_file", type=str, help="Backup zip file destination")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
backup_file = options["backup_file"]
|
||||
with zipfile.ZipFile(backup_file, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Backup the database
|
||||
self.stdout.write("Create database backup...")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
backup_db_file = os.path.join(temp_dir, "db.sqlite3")
|
||||
self.backup_database(backup_db_file)
|
||||
zip_file.write(backup_db_file, "db.sqlite3")
|
||||
|
||||
# Backup the assets folder
|
||||
if not os.path.exists(os.path.join("data", "assets")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No assets folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark assets...")
|
||||
assets_folder = os.path.join("data", "assets")
|
||||
for root, _, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("assets", file))
|
||||
|
||||
# Backup the favicons folder
|
||||
if not os.path.exists(os.path.join("data", "favicons")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No favicons folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark favicons...")
|
||||
favicons_folder = os.path.join("data", "favicons")
|
||||
for root, _, files in os.walk(favicons_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("favicons", file))
|
||||
|
||||
# Backup the previews folder
|
||||
if not os.path.exists(os.path.join("data", "previews")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No previews folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark previews...")
|
||||
previews_folder = os.path.join("data", "previews")
|
||||
for root, _, files in os.walk(previews_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("previews", file))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
|
||||
|
||||
def backup_database(self, backup_db_file):
|
||||
def progress(status, remaining, total):
|
||||
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
|
||||
|
||||
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
backup_db = sqlite3.connect(backup_db_file)
|
||||
with backup_db:
|
||||
source_db.backup(backup_db, pages=50, progress=progress)
|
||||
backup_db.close()
|
||||
source_db.close()
|
75
bookmarks/management/commands/migrate_tasks.py
Normal file
75
bookmarks/management/commands/migrate_tasks.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import importlib
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migrate tasks from django-background-tasks to Huey"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
|
||||
# Check if background_task table exists
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='background_task'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
self.stdout.write(
|
||||
"Legacy task table does not exist. Skipping task migration"
|
||||
)
|
||||
return
|
||||
|
||||
# Load legacy tasks
|
||||
cursor.execute("SELECT id, task_name, task_params FROM background_task")
|
||||
legacy_tasks = cursor.fetchall()
|
||||
|
||||
if len(legacy_tasks) == 0:
|
||||
self.stdout.write("No legacy tasks found. Skipping task migration")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {len(legacy_tasks)} legacy tasks. Migrating to Huey..."
|
||||
)
|
||||
|
||||
# Migrate tasks to Huey
|
||||
succeeded_tasks = []
|
||||
for task in legacy_tasks:
|
||||
task_id = task[0]
|
||||
task_name = task[1]
|
||||
task_params_json = task[2]
|
||||
try:
|
||||
task_params = json.loads(task_params_json)
|
||||
function_params = task_params[0]
|
||||
|
||||
# Resolve task function
|
||||
module_name, func_name = task_name.rsplit(".", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
func = getattr(module, func_name)
|
||||
|
||||
# Call task function
|
||||
func(*function_params)
|
||||
succeeded_tasks.append(task_id)
|
||||
except Exception:
|
||||
self.stderr.write(f"Error migrating task [{task_id}] {task_name}")
|
||||
|
||||
self.stdout.write(f"Migrated {len(succeeded_tasks)} tasks successfully")
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
placeholders = ", ".join("?" for _ in succeeded_tasks)
|
||||
sql = f"DELETE FROM background_task WHERE id IN ({placeholders})"
|
||||
cursor.execute(sql, succeeded_tasks)
|
||||
db.commit()
|
||||
self.stdout.write(
|
||||
f"Deleted {len(succeeded_tasks)} migrated tasks from legacy table"
|
||||
)
|
||||
except Exception:
|
||||
self.stderr.write("Error cleaning up legacy tasks")
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
@@ -1,23 +1,40 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.models import UserProfile, GlobalSettings
|
||||
|
||||
|
||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||
|
||||
|
||||
class UserProfileMiddleware:
|
||||
default_global_settings = GlobalSettings()
|
||||
|
||||
standard_profile = UserProfile()
|
||||
standard_profile.enable_favicons = True
|
||||
|
||||
|
||||
class LinkdingMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# add global settings to request
|
||||
try:
|
||||
global_settings = GlobalSettings.get()
|
||||
except:
|
||||
global_settings = default_global_settings
|
||||
request.global_settings = global_settings
|
||||
|
||||
# add user profile to request
|
||||
if request.user.is_authenticated:
|
||||
request.user_profile = request.user.profile
|
||||
else:
|
||||
request.user_profile = UserProfile()
|
||||
request.user_profile.enable_favicons = True
|
||||
# check if a custom profile for guests exists, otherwise use standard profile
|
||||
if global_settings.guest_profile_user:
|
||||
request.user_profile = global_settings.guest_profile_user.profile
|
||||
else:
|
||||
request.user_profile = standard_profile
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
|
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-23 21:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0026_userprofile_custom_css"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="bookmark_description_display",
|
||||
field=models.CharField(
|
||||
choices=[("inline", "Inline"), ("separate", "Separate")],
|
||||
default="inline",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="bookmark_description_max_lines",
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
]
|
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-29 20:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_archive_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_edit_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_remove_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_view_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
34
bookmarks/migrations/0029_bookmark_list_actions_toast.py
Normal file
34
bookmarks/migrations/0029_bookmark_list_actions_toast.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-29 21:25
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
|
||||
for user in User.objects.all():
|
||||
toast = Toast(
|
||||
key="bookmark_list_actions_hint",
|
||||
message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
43
bookmarks/migrations/0030_bookmarkasset.py
Normal file
43
bookmarks/migrations/0030_bookmarkasset.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-31 08:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0029_bookmark_list_actions_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BookmarkAsset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||
("file", models.CharField(blank=True, max_length=2048)),
|
||||
("file_size", models.IntegerField(null=True)),
|
||||
("asset_type", models.CharField(max_length=64)),
|
||||
("content_type", models.CharField(max_length=128)),
|
||||
("display_name", models.CharField(blank=True, max_length=2048)),
|
||||
("status", models.CharField(max_length=64)),
|
||||
("gzip", models.BooleanField(default=False)),
|
||||
(
|
||||
"bookmark",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bookmarks.bookmark",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-01 10:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0030_bookmarkasset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="enable_automatic_html_snapshots",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
34
bookmarks/migrations/0032_html_snapshots_hint_toast.py
Normal file
34
bookmarks/migrations/0032_html_snapshots_hint_toast.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-01 12:17
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
|
||||
for user in User.objects.all():
|
||||
toast = Toast(
|
||||
key="html_snapshots_hint",
|
||||
message="This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
18
bookmarks/migrations/0033_userprofile_default_mark_unread.py
Normal file
18
bookmarks/migrations/0033_userprofile_default_mark_unread.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-17 19:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0032_html_snapshots_hint_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_mark_unread",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-10 07:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0033_userprofile_default_mark_unread"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="preview_image_file",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="enable_preview_images",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-14 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="tag_grouping",
|
||||
field=models.CharField(
|
||||
choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")],
|
||||
default="alphabetical",
|
||||
max_length=12,
|
||||
),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-17 07:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0035_userprofile_tag_grouping"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="auto_tagging_rules",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
38
bookmarks/migrations/0037_globalsettings.py
Normal file
38
bookmarks/migrations/0037_globalsettings.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.0.8 on 2024-08-31 12:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0036_userprofile_auto_tagging_rules"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GlobalSettings",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"landing_page",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("shared_bookmarks", "Shared Bookmarks"),
|
||||
],
|
||||
default="login",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.8 on 2024-08-31 17:54
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0037_globalsettings"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="globalsettings",
|
||||
name="guest_profile_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.8 on 2024-09-14 07:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0038_globalsettings_guest_profile_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="globalsettings",
|
||||
name="enable_link_prefetch",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -1,18 +1,22 @@
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
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.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import QueryDict
|
||||
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
@@ -55,6 +59,7 @@ class Bookmark(models.Model):
|
||||
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)
|
||||
preview_image_file = models.CharField(max_length=512, blank=True)
|
||||
unread = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
shared = models.BooleanField(default=False)
|
||||
@@ -79,12 +84,58 @@ class Bookmark(models.Model):
|
||||
|
||||
@property
|
||||
def tag_names(self):
|
||||
return [tag.name for tag in self.tags.all()]
|
||||
names = [tag.name for tag in self.tags.all()]
|
||||
return sorted(names)
|
||||
|
||||
def __str__(self):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
|
||||
class BookmarkAsset(models.Model):
|
||||
TYPE_SNAPSHOT = "snapshot"
|
||||
TYPE_UPLOAD = "upload"
|
||||
|
||||
CONTENT_TYPE_HTML = "text/html"
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_COMPLETE = "complete"
|
||||
STATUS_FAILURE = "failure"
|
||||
|
||||
bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE)
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||
file = models.CharField(max_length=2048, blank=True, null=False)
|
||||
file_size = models.IntegerField(null=True)
|
||||
asset_type = models.CharField(max_length=64, blank=False, null=False)
|
||||
content_type = models.CharField(max_length=128, blank=False, null=False)
|
||||
display_name = models.CharField(max_length=2048, blank=True, null=False)
|
||||
status = models.CharField(max_length=64, blank=False, null=False)
|
||||
gzip = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.file:
|
||||
try:
|
||||
file_path = os.path.join(settings.LD_ASSET_FOLDER, self.file)
|
||||
if os.path.isfile(file_path):
|
||||
self.file_size = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name or f"Bookmark Asset #{self.pk}"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=BookmarkAsset)
|
||||
def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, instance.file)
|
||||
if os.path.isfile(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as error:
|
||||
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()])
|
||||
@@ -119,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.instance and self.instance.notes
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
@@ -278,6 +331,12 @@ class UserProfile(models.Model):
|
||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
|
||||
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
|
||||
]
|
||||
BOOKMARK_DESCRIPTION_DISPLAY_INLINE = "inline"
|
||||
BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = "separate"
|
||||
BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [
|
||||
(BOOKMARK_DESCRIPTION_DISPLAY_INLINE, "Inline"),
|
||||
(BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, "Separate"),
|
||||
]
|
||||
BOOKMARK_LINK_TARGET_BLANK = "_blank"
|
||||
BOOKMARK_LINK_TARGET_SELF = "_self"
|
||||
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||
@@ -296,6 +355,12 @@ class UserProfile(models.Model):
|
||||
(TAG_SEARCH_STRICT, "Strict"),
|
||||
(TAG_SEARCH_LAX, "Lax"),
|
||||
]
|
||||
TAG_GROUPING_ALPHABETICAL = "alphabetical"
|
||||
TAG_GROUPING_DISABLED = "disabled"
|
||||
TAG_GROUPING_CHOICES = [
|
||||
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||
]
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
@@ -308,6 +373,16 @@ class UserProfile(models.Model):
|
||||
blank=False,
|
||||
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||
)
|
||||
bookmark_description_display = models.CharField(
|
||||
max_length=10,
|
||||
choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,
|
||||
blank=False,
|
||||
default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
||||
)
|
||||
bookmark_description_max_lines = models.IntegerField(
|
||||
null=False,
|
||||
default=1,
|
||||
)
|
||||
bookmark_link_target = models.CharField(
|
||||
max_length=10,
|
||||
choices=BOOKMARK_LINK_TARGET_CHOICES,
|
||||
@@ -326,13 +401,27 @@ class UserProfile(models.Model):
|
||||
blank=False,
|
||||
default=TAG_SEARCH_STRICT,
|
||||
)
|
||||
tag_grouping = models.CharField(
|
||||
max_length=12,
|
||||
choices=TAG_GROUPING_CHOICES,
|
||||
blank=False,
|
||||
default=TAG_GROUPING_ALPHABETICAL,
|
||||
)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
enable_preview_images = models.BooleanField(default=False, null=False)
|
||||
display_url = models.BooleanField(default=False, null=False)
|
||||
display_view_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
display_archive_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -341,15 +430,26 @@ class UserProfileForm(forms.ModelForm):
|
||||
fields = [
|
||||
"theme",
|
||||
"bookmark_date_display",
|
||||
"bookmark_description_display",
|
||||
"bookmark_description_max_lines",
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"tag_grouping",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"enable_preview_images",
|
||||
"enable_automatic_html_snapshots",
|
||||
"display_url",
|
||||
"display_view_bookmark_action",
|
||||
"display_edit_bookmark_action",
|
||||
"display_archive_bookmark_action",
|
||||
"display_remove_bookmark_action",
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
]
|
||||
|
||||
|
||||
@@ -395,3 +495,46 @@ class FeedToken(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
|
||||
|
||||
class GlobalSettings(models.Model):
|
||||
LANDING_PAGE_LOGIN = "login"
|
||||
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
|
||||
LANDING_PAGE_CHOICES = [
|
||||
(LANDING_PAGE_LOGIN, "Login"),
|
||||
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
|
||||
]
|
||||
|
||||
landing_page = models.CharField(
|
||||
max_length=50,
|
||||
choices=LANDING_PAGE_CHOICES,
|
||||
blank=False,
|
||||
default=LANDING_PAGE_LOGIN,
|
||||
)
|
||||
guest_profile_user = models.ForeignKey(
|
||||
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
enable_link_prefetch = models.BooleanField(default=False, null=False)
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
instance = GlobalSettings.objects.first()
|
||||
if not instance:
|
||||
instance = GlobalSettings()
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk and GlobalSettings.objects.exists():
|
||||
raise Exception("There is already one instance of GlobalSettings")
|
||||
return super(GlobalSettings, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class GlobalSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GlobalSettings
|
||||
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
|
||||
self.fields["guest_profile_user"].empty_label = "Standard profile"
|
||||
|
64
bookmarks/services/auto_tagging.py
Normal file
64
bookmarks/services/auto_tagging.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import re
|
||||
import idna
|
||||
|
||||
|
||||
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]
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
# to parse a host name from the pattern URL, ensure it has a scheme
|
||||
pattern_url = "//" + re.sub("^https?://", "", parts[0])
|
||||
parsed_pattern = urlparse(pattern_url)
|
||||
|
||||
if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):
|
||||
continue
|
||||
|
||||
if parsed_pattern.path and not _path_matches(
|
||||
parsed_pattern.path, parsed_url.path
|
||||
):
|
||||
continue
|
||||
|
||||
if parsed_pattern.query and not _qs_matches(
|
||||
parsed_pattern.query, parsed_url.query
|
||||
):
|
||||
continue
|
||||
|
||||
for tag in parts[1:]:
|
||||
result.add(tag)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _path_matches(expected_path: str, actual_path: str) -> bool:
|
||||
return actual_path.startswith(expected_path)
|
||||
|
||||
|
||||
def _domains_matches(expected_domain: str, actual_domain: str) -> bool:
|
||||
expected_domain = idna.encode(expected_domain)
|
||||
actual_domain = idna.encode(actual_domain)
|
||||
|
||||
return actual_domain.endswith(expected_domain)
|
||||
|
||||
|
||||
def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||
expected_qs = parse_qs(expected_qs, keep_blank_values=True)
|
||||
actual_qs = parse_qs(actual_qs, keep_blank_values=True)
|
||||
|
||||
for key in expected_qs:
|
||||
if key not in actual_qs:
|
||||
return False
|
||||
for value in expected_qs[key]:
|
||||
if value != "" and value not in actual_qs[key]:
|
||||
return False
|
||||
|
||||
return True
|
@@ -1,12 +1,19 @@
|
||||
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, parse_tag_string
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||
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):
|
||||
@@ -34,6 +41,11 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||
# Load favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -49,6 +61,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
bookmark.save()
|
||||
# Update favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Update preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
@@ -173,6 +187,46 @@ 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,
|
||||
)
|
||||
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
|
||||
|
||||
asset.save()
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
@@ -189,6 +243,21 @@ def _update_website_metadata(bookmark: Bookmark):
|
||||
|
||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
|
||||
if user.profile.auto_tagging_rules:
|
||||
try:
|
||||
auto_tag_names = auto_tagging.get_tags(
|
||||
user.profile.auto_tagging_rules, bookmark.url
|
||||
)
|
||||
for auto_tag_name in auto_tag_names:
|
||||
if auto_tag_name not in tag_names:
|
||||
tag_names.append(auto_tag_name)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
bookmark.tags.set(tags)
|
||||
|
||||
|
@@ -79,10 +79,10 @@ def import_netscape_html(
|
||||
for batch in batches:
|
||||
_import_batch(batch, user, options, tag_cache, result)
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
# Load favicons for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
# Load previews for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_previews(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f"Import duration: {end - import_start}")
|
||||
|
32
bookmarks/services/monolith.py
Normal file
32
bookmarks/services/monolith.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import gzip
|
||||
import shutil
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class MonolithError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Monolith isn't used at the moment, as the local snapshot implementation
|
||||
# switched to single-file after the prototype. Keeping this around in case
|
||||
# it turns out to be useful in the future.
|
||||
def create_snapshot(url: str, filepath: str):
|
||||
monolith_path = settings.LD_MONOLITH_PATH
|
||||
monolith_options = settings.LD_MONOLITH_OPTIONS
|
||||
temp_filepath = filepath + ".tmp"
|
||||
|
||||
try:
|
||||
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
|
||||
subprocess.run(command, check=True, shell=True)
|
||||
|
||||
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.CalledProcessError as error:
|
||||
raise MonolithError(f"Failed to create snapshot: {error.stderr}")
|
88
bookmarks/services/preview_image_loader.py
Normal file
88
bookmarks/services/preview_image_loader.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import os.path
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_preview_folder():
|
||||
Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _url_to_filename(preview_image: str) -> str:
|
||||
return hashlib.md5(preview_image.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_image_path(preview_image_file: str) -> Path:
|
||||
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file))
|
||||
|
||||
|
||||
def load_preview_image(url: str) -> str | None:
|
||||
_ensure_preview_folder()
|
||||
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
if not metadata.preview_image:
|
||||
logger.debug(f"Could not find preview image in metadata: {url}")
|
||||
return None
|
||||
|
||||
image_url = metadata.preview_image
|
||||
|
||||
logger.debug(f"Loading preview image: {image_url}")
|
||||
with requests.get(image_url, stream=True) as response:
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.debug(
|
||||
f"Bad response status code for preview image: {image_url} status_code={response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
if "Content-Length" not in response.headers:
|
||||
logger.debug(f"Empty Content-Length for preview image: {image_url}")
|
||||
return None
|
||||
|
||||
content_length = int(response.headers["Content-Length"])
|
||||
if content_length > settings.LD_PREVIEW_MAX_SIZE:
|
||||
logger.debug(
|
||||
f"Content-Length exceeds LD_PREVIEW_MAX_SIZE: {image_url} length={content_length}"
|
||||
)
|
||||
return None
|
||||
|
||||
if "Content-Type" not in response.headers:
|
||||
logger.debug(f"Empty Content-Type for preview image: {image_url}")
|
||||
return None
|
||||
|
||||
content_type = response.headers["Content-Type"].split(";", 1)[0]
|
||||
file_extension = mimetypes.guess_extension(content_type)
|
||||
|
||||
if file_extension not in settings.LD_PREVIEW_ALLOWED_EXTENSIONS:
|
||||
logger.debug(
|
||||
f"Unsupported Content-Type for preview image: {image_url} content_type={content_type}"
|
||||
)
|
||||
return None
|
||||
|
||||
preview_image_hash = _url_to_filename(url)
|
||||
preview_image_file = f"{preview_image_hash}{file_extension}"
|
||||
preview_image_path = _get_image_path(preview_image_file)
|
||||
|
||||
with open(preview_image_path, "wb") as file:
|
||||
downloaded = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
downloaded += len(chunk)
|
||||
if downloaded > content_length:
|
||||
logger.debug(
|
||||
f"Content-Length mismatch for preview image: {image_url} length={content_length} downloaded={downloaded}"
|
||||
)
|
||||
file.close()
|
||||
preview_image_path.unlink()
|
||||
return None
|
||||
|
||||
file.write(chunk)
|
||||
|
||||
logger.debug(f"Saved preview image as: {preview_image_path}")
|
||||
|
||||
return preview_image_file
|
58
bookmarks/services/singlefile.py
Normal file
58
bookmarks/services/singlefile.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SingleFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
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]
|
||||
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):
|
||||
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:
|
||||
logger.error(
|
||||
"Timeout expired while creating snapshot. Terminating process..."
|
||||
)
|
||||
process.terminate()
|
||||
process.wait(timeout=20)
|
||||
raise SingleFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the whole process group, which should also clean up any chromium
|
||||
# processes spawned by single-file
|
||||
logger.error("Timeout expired while terminating. Killing process...")
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
raise SingleFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise SingleFileError(f"Failed to create snapshot: {error.stderr}")
|
@@ -1,21 +1,55 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import waybackpy
|
||||
from background_task import background
|
||||
from background_task.models import Task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone, formats
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from huey.exceptions import TaskLockedException
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.services import favicon_loader
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Create custom decorator for Huey tasks that implements exponential backoff
|
||||
# Taken from: https://huey.readthedocs.io/en/latest/guide.html#tips-and-tricks
|
||||
# Retry 1: 60
|
||||
# Retry 2: 240
|
||||
# Retry 3: 960
|
||||
# Retry 4: 3840
|
||||
# Retry 5: 15360
|
||||
def task(retries=5, retry_delay=15, retry_backoff=4):
|
||||
def deco(fn):
|
||||
@functools.wraps(fn)
|
||||
def inner(*args, **kwargs):
|
||||
task = kwargs.pop("task")
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except TaskLockedException as exc:
|
||||
# Task locks are currently only used as workaround to enforce
|
||||
# running specific types of tasks (e.g. singlefile snapshots)
|
||||
# sequentially. In that case don't reduce the number of retries.
|
||||
task.retries = retries
|
||||
raise exc
|
||||
except Exception as exc:
|
||||
task.retry_delay *= retry_backoff
|
||||
raise exc
|
||||
|
||||
return huey.task(retries=retries, retry_delay=retry_delay, context=True)(inner)
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def is_web_archive_integration_active(user: User) -> bool:
|
||||
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
web_archive_integration_enabled = (
|
||||
@@ -31,29 +65,6 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
|
||||
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
||||
|
||||
|
||||
def _load_newest_snapshot(bookmark: Bookmark):
|
||||
try:
|
||||
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
|
||||
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
|
||||
bookmark.url
|
||||
)
|
||||
existing_snapshot = cdx_api.newest()
|
||||
|
||||
if existing_snapshot:
|
||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
||||
bookmark.save(update_fields=["web_archive_snapshot_url"])
|
||||
logger.info(
|
||||
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
|
||||
)
|
||||
|
||||
except NoCDXRecordFound:
|
||||
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
|
||||
except WaybackError as error:
|
||||
logger.error(
|
||||
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
|
||||
)
|
||||
|
||||
|
||||
def _create_snapshot(bookmark: Bookmark):
|
||||
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
||||
archive = waybackpy.WaybackMachineSaveAPI(
|
||||
@@ -65,7 +76,7 @@ def _create_snapshot(bookmark: Bookmark):
|
||||
logger.info(f"Successfully created new snapshot for bookmark:. url={bookmark.url}")
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
@@ -82,47 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
return
|
||||
except TooManyRequestsError:
|
||||
logger.error(
|
||||
f"Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}"
|
||||
f"Failed to create snapshot due to rate limiting. url={bookmark.url}"
|
||||
)
|
||||
except WaybackError as error:
|
||||
logger.error(
|
||||
f"Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}",
|
||||
f"Failed to create snapshot. url={bookmark.url}",
|
||||
exc_info=error,
|
||||
)
|
||||
|
||||
# Load the newest snapshot as fallback
|
||||
_load_newest_snapshot(bookmark)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
# Skip if snapshot exists
|
||||
if bookmark.web_archive_snapshot_url:
|
||||
return
|
||||
# Load the newest snapshot
|
||||
_load_newest_snapshot(bookmark)
|
||||
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||
# for now to prevent errors when huey tries to run the task
|
||||
pass
|
||||
|
||||
|
||||
def schedule_bookmarks_without_snapshots(user: User):
|
||||
if is_web_archive_integration_active(user):
|
||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
||||
web_archive_snapshot_url__exact="", owner=user
|
||||
)
|
||||
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||
# new ones when processing bookmarks in bulk
|
||||
_load_web_archive_snapshot_task(bookmark.id)
|
||||
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||
# for now to prevent errors when huey tries to run the task
|
||||
pass
|
||||
|
||||
|
||||
def is_favicon_feature_active(user: User) -> bool:
|
||||
@@ -131,12 +122,18 @@ def is_favicon_feature_active(user: User) -> bool:
|
||||
return background_tasks_enabled and user.profile.enable_favicons
|
||||
|
||||
|
||||
def is_preview_feature_active(user: User) -> bool:
|
||||
return (
|
||||
user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
)
|
||||
|
||||
|
||||
def load_favicon(user: User, bookmark: Bookmark):
|
||||
if is_favicon_feature_active(user):
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _load_favicon_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
@@ -160,19 +157,15 @@ def schedule_bookmarks_without_favicons(user: User):
|
||||
_schedule_bookmarks_without_favicons_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
|
||||
tasks = []
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(
|
||||
task_name="bookmarks.services.tasks._load_favicon_task", args=(bookmark.id,)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
_load_favicon_task(bookmark.id)
|
||||
pass
|
||||
|
||||
|
||||
def schedule_refresh_favicons(user: User):
|
||||
@@ -180,16 +173,180 @@ def schedule_refresh_favicons(user: User):
|
||||
_schedule_refresh_favicons_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _schedule_refresh_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(owner=user)
|
||||
tasks = []
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(
|
||||
task_name="bookmarks.services.tasks._load_favicon_task", args=(bookmark.id,)
|
||||
)
|
||||
tasks.append(task)
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
|
||||
def load_preview_image(user: User, bookmark: Bookmark):
|
||||
if is_preview_feature_active(user):
|
||||
_load_preview_image_task(bookmark.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _load_preview_image_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f"Load preview image for bookmark. url={bookmark.url}")
|
||||
|
||||
new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url)
|
||||
|
||||
if new_preview_image_file != bookmark.preview_image_file:
|
||||
bookmark.preview_image_file = new_preview_image_file or ""
|
||||
bookmark.save(update_fields=["preview_image_file"])
|
||||
logger.info(
|
||||
f"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}"
|
||||
)
|
||||
|
||||
|
||||
def schedule_bookmarks_without_previews(user: User):
|
||||
if is_preview_feature_active(user):
|
||||
_schedule_bookmarks_without_previews_task(user.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(
|
||||
Q(preview_image_file__exact=""),
|
||||
owner=user,
|
||||
)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
try:
|
||||
_load_preview_image_task(bookmark.id)
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
|
||||
|
||||
def is_html_snapshot_feature_active() -> bool:
|
||||
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
|
||||
def create_html_snapshot(bookmark: Bookmark):
|
||||
if not is_html_snapshot_feature_active():
|
||||
return
|
||||
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
|
||||
|
||||
def create_html_snapshots(bookmark_list: List[Bookmark]):
|
||||
if not is_html_snapshot_feature_active():
|
||||
return
|
||||
|
||||
assets_to_create = []
|
||||
for bookmark in bookmark_list:
|
||||
asset = _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
|
||||
# sequence. The task uses a lock to ensure that a new task isn't scheduled
|
||||
# before the previous one has finished.
|
||||
@huey.periodic_task(crontab(minute="*"))
|
||||
@huey.lock_task("schedule-html-snapshots-lock")
|
||||
def _schedule_html_snapshots_task():
|
||||
# Get five pending assets
|
||||
assets = BookmarkAsset.objects.filter(status=BookmarkAsset.STATUS_PENDING).order_by(
|
||||
"date_created"
|
||||
)[:5]
|
||||
|
||||
for asset in assets:
|
||||
_create_html_snapshot_task(asset.id)
|
||||
|
||||
|
||||
def _create_html_snapshot_task(asset_id: int):
|
||||
try:
|
||||
asset = BookmarkAsset.objects.get(id=asset_id)
|
||||
except BookmarkAsset.DoesNotExist:
|
||||
return
|
||||
|
||||
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()
|
||||
logger.info(
|
||||
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
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:
|
||||
if not is_html_snapshot_feature_active():
|
||||
return 0
|
||||
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(owner=user).exclude(
|
||||
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
bookmarkasset__status__in=[
|
||||
BookmarkAsset.STATUS_PENDING,
|
||||
BookmarkAsset.STATUS_COMPLETE,
|
||||
],
|
||||
)
|
||||
bookmarks_without_snapshots |= Bookmark.objects.filter(owner=user).exclude(
|
||||
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT
|
||||
)
|
||||
|
||||
create_html_snapshots(list(bookmarks_without_snapshots))
|
||||
|
||||
return bookmarks_without_snapshots.count()
|
||||
|
@@ -1,42 +1,20 @@
|
||||
import time
|
||||
from typing import Dict
|
||||
import datetime
|
||||
|
||||
import waybackpy
|
||||
import waybackpy.utils
|
||||
from waybackpy.exceptions import NoCDXRecordFound
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
||||
def generate_fallback_webarchive_url(
|
||||
url: str, timestamp: datetime.datetime
|
||||
) -> str | None:
|
||||
"""
|
||||
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
|
||||
See https://github.com/akamhy/waybackpy/issues/176
|
||||
Generate a URL to the web archive for the given URL and timestamp.
|
||||
A snapshot for the specific timestamp might not exist, in which case the
|
||||
web archive will show the closest snapshot to the given timestamp.
|
||||
If there is no snapshot at all the URL will be invalid.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
if not timestamp:
|
||||
timestamp = timezone.now()
|
||||
|
||||
def newest(self):
|
||||
unix_timestamp = int(time.time())
|
||||
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
|
||||
unix_timestamp
|
||||
)
|
||||
self.sort = "closest"
|
||||
self.limit = -5
|
||||
|
||||
newest_snapshot = None
|
||||
for snapshot in self.snapshots():
|
||||
newest_snapshot = snapshot
|
||||
break
|
||||
|
||||
if not newest_snapshot:
|
||||
raise NoCDXRecordFound(
|
||||
"Wayback Machine's CDX server did not return any records "
|
||||
+ "for the query. The URL may not have any archives "
|
||||
+ " on the Wayback Machine or the URL may have been recently "
|
||||
+ "archived and is still not available on the CDX server."
|
||||
)
|
||||
|
||||
return newest_snapshot
|
||||
|
||||
def add_payload(self, payload: Dict[str, str]) -> None:
|
||||
super().add_payload(payload)
|
||||
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
|
||||
# makes searching for latest snapshots faster
|
||||
payload["fastLatest"] = "true"
|
||||
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -15,12 +16,14 @@ class WebsiteMetadata:
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
preview_image: str | None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"preview_image": self.preview_image,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +33,7 @@ class WebsiteMetadata:
|
||||
def load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
try:
|
||||
start = timezone.now()
|
||||
page_text = load_page(url)
|
||||
@@ -55,10 +59,21 @@ def load_website_metadata(url: str):
|
||||
else None
|
||||
)
|
||||
|
||||
image_tag = soup.find("meta", attrs={"property": "og:image"})
|
||||
preview_image = image_tag["content"].strip() if image_tag else None
|
||||
if (
|
||||
preview_image
|
||||
and not preview_image.startswith("http://")
|
||||
and not preview_image.startswith("https://")
|
||||
):
|
||||
preview_image = urljoin(url, preview_image)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f"Parsing duration: {end - start}")
|
||||
finally:
|
||||
return WebsiteMetadata(url=url, title=title, description=description)
|
||||
return WebsiteMetadata(
|
||||
url=url, title=title, description=description, preview_image=preview_image
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 50 * 1024
|
||||
|
@@ -1,15 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import user_logged_in
|
||||
from django.db.backends.signals import connection_created
|
||||
from django.dispatch import receiver
|
||||
|
||||
from bookmarks.services import tasks
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def user_logged_in(sender, request, user, **kwargs):
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
|
||||
|
||||
@receiver(connection_created)
|
||||
def extend_sqlite(connection=None, **kwargs):
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 447 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
2314
bookmarks/static/vendor/Readability.js
vendored
Normal file
2314
bookmarks/static/vendor/Readability.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,144 +0,0 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: $size-sm) {
|
||||
// Horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: $unit-10;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 $unit-3;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared components */
|
||||
|
||||
// Content area component
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-6;
|
||||
padding-bottom: $unit-2;
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.8rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm button component
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-1;
|
||||
color: $error-color !important;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional utilities */
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.btn-wide {
|
||||
padding-left: $unit-6;
|
||||
padding-right: $unit-6;
|
||||
}
|
||||
|
||||
.btn.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-h;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
150
bookmarks/styles/bookmark-details.css
Normal file
150
bookmarks/styles/bookmark-details.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink img, & a.weblink svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
& a.weblink span {
|
||||
flex: 1 1 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
margin: var(--unit-4 0);
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& .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 {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .tags a {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
|
||||
& .status form {
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .status .form-group, .status .form-switch {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark details view specific */
|
||||
.bookmark-details.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-6);
|
||||
}
|
||||
|
||||
/* Bookmark details modal specific */
|
||||
.bookmark-details.modal {
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
48
bookmarks/styles/bookmark-form.css
Normal file
48
bookmarks/styles/bookmark-form.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.bookmarks-form-page {
|
||||
section {
|
||||
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 {
|
||||
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-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
& .form-input-hint.auto-tags {
|
||||
display: none;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
& details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.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-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
457
bookmarks/styles/bookmark-page.css
Normal file
457
bookmarks/styles/bookmark-page.css
Normal file
@@ -0,0 +1,457 @@
|
||||
: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 page grid */
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .search-container {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
max-width: 300px;
|
||||
margin-left: auto;
|
||||
|
||||
& form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
max-width: initial;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
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);
|
||||
}
|
||||
|
||||
& .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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
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;
|
||||
}
|
||||
|
||||
& 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
/* Remove left padding from first pagination link */
|
||||
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
& .selected-tags {
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .unselected-tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
|
||||
& .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 .markdown {
|
||||
padding: var(--unit-2) var(--unit-3);
|
||||
}
|
||||
|
||||
&.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;
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
&.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 section:first-of-type .content-area-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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,398 +0,0 @@
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: $unit-10;
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .content-area-header {
|
||||
--searchbox-max-width: 350px;
|
||||
--searchbox-height: 1.8rem;
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
--searchbox-max-width: initial;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-page .search-container {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
// Regular input
|
||||
input[type='search'] {
|
||||
height: var(--searchbox-height);
|
||||
-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(--searchbox-height);
|
||||
|
||||
.form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: var(--searchbox-height);
|
||||
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1 1 0;
|
||||
min-width: var(--searchbox-min-width);
|
||||
max-width: var(--searchbox-max-width);
|
||||
}
|
||||
|
||||
.input-group > :first-child {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
// Group search options button with search button
|
||||
.input-group input[type='submit'] {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
// Search option menu styles
|
||||
.dropdown {
|
||||
.menu {
|
||||
padding: $unit-4;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.menu .actions {
|
||||
margin-top: $unit-4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
margin-bottom: $unit-1;
|
||||
.form-label {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.form-radio.form-inline {
|
||||
margin: 0 $unit-2 0 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: $unit-1;
|
||||
}
|
||||
.form-icon {
|
||||
top: 0;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
|
||||
[ld-bulk-edit-checkbox].form-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title a {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: $unit-1;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid #424a8c;
|
||||
font-size: $font-size-sm;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.title img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: $unit-h;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
color: $secondary-link-color;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
}
|
||||
|
||||
.actions, .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-2;
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
.extra-actions {
|
||||
width: 100%;
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
a, button.btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: $unit-4;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
|
||||
.selected-tags {
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
a, a:visited:hover {
|
||||
color: $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
.unselected-tags {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: $unit-2;
|
||||
}
|
||||
|
||||
.highlight-char {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: $alternative-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
.notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: $unit-1 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes markdown styles */
|
||||
ul.bookmark-list .notes-content {
|
||||
& {
|
||||
padding: $unit-2 $unit-3;
|
||||
}
|
||||
|
||||
p, ul, ol, pre, blockquote {
|
||||
margin: 0 0 $unit-2 0;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: $unit-4;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: $unit-1 $unit-2;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: $unit-1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark bulk edit */
|
||||
$bulk-edit-toggle-width: 16px;
|
||||
$bulk-edit-toggle-offset: 8px;
|
||||
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
||||
$bulk-edit-transition-duration: 400ms;
|
||||
|
||||
[ld-bulk-edit] {
|
||||
.bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: -$bulk-edit-bar-offset;
|
||||
margin-bottom: $unit-4;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $bulk-edit-transition-duration;
|
||||
}
|
||||
|
||||
&.active .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
|
||||
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
||||
&.active:not(.activating) .bulk-edit-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
[ld-bulk-edit-checkbox][all].form-checkbox {
|
||||
display: block;
|
||||
width: $bulk-edit-toggle-width;
|
||||
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||
padding: 0;
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: $bulk-edit-toggle-width;
|
||||
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
.form-icon {
|
||||
top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: $unit-1 0;
|
||||
border-top: solid 1px $border-color;
|
||||
gap: $unit-2;
|
||||
|
||||
button {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
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: $font-size-sm;
|
||||
}
|
||||
}
|
||||
}
|
65
bookmarks/styles/components.css
Normal file
65
bookmarks/styles/components.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* Shared components */
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
section.content-area .content-area-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-5) 0;
|
||||
}
|
||||
|
||||
/* Turbo progress bar */
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
39
bookmarks/styles/layout.css
Normal file
39
bookmarks/styles/layout.css
Normal file
@@ -0,0 +1,39 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@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);
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
40
bookmarks/styles/markdown.css
Normal file
40
bookmarks/styles/markdown.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.markdown {
|
||||
& p, & ul, & ol, & pre, & blockquote {
|
||||
margin: 0 0 var(--unit-2) 0;
|
||||
}
|
||||
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& ul, & ol {
|
||||
margin-left: var(--unit-4);
|
||||
}
|
||||
|
||||
& 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 code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& > pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
27
bookmarks/styles/reader-mode.css
Normal file
27
bookmarks/styles/reader-mode.css
Normal file
@@ -0,0 +1,27 @@
|
||||
html.reader-mode {
|
||||
--font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
|
||||
body {
|
||||
margin: 3rem 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reading-time {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,3 @@
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: $size-lg;
|
||||
}
|
||||
|
||||
.show-sm,
|
||||
.show-md {
|
||||
display: none !important;
|
||||
@@ -26,30 +19,45 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: var(--size-lg);
|
||||
}
|
||||
|
||||
.grid {
|
||||
--grid-columns: 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||
grid-gap: $unit-4;
|
||||
grid-gap: var(--unit-4);
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.columns-2 {
|
||||
--grid-columns: 2;
|
||||
}
|
||||
|
||||
.gap-0 {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.col-1 {
|
||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||
grid-column: span min(1, var(--grid-columns));
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
grid-column: unquote("span min(2, var(--grid-columns))");
|
||||
grid-column: span min(2, var(--grid-columns));
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
grid-column: unquote("span min(3, var(--grid-columns))");
|
||||
grid-column: span min(3, var(--grid-columns));
|
||||
}
|
||||
|
||||
@media (max-width: $size-md) {
|
||||
@media (max-width: 840px) {
|
||||
.hide-md {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -78,7 +86,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
@media (max-width: 600px) {
|
||||
.hide-sm {
|
||||
display: none !important;
|
||||
}
|
26
bookmarks/styles/settings.css
Normal file
26
bookmarks/styles/settings.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: var(--unit-10);
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
}
|
||||
|
||||
textarea.monospace {
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
section.about table {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
& .form-group {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: $unit-12;
|
||||
|
||||
h2 {
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.custom-css {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
section.about table {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
@@ -1,137 +0,0 @@
|
||||
// Customized Spectre CSS imports, removing modules that are not used
|
||||
// See node_modules/spectre.css/src/spectre.scss for the original version
|
||||
|
||||
// Variables and mixins
|
||||
@import "../../node_modules/spectre.css/src/variables";
|
||||
@import "../../node_modules/spectre.css/src/mixins";
|
||||
|
||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
||||
// Reset and dependencies
|
||||
@import "../../node_modules/spectre.css/src/normalize";
|
||||
@import "../../node_modules/spectre.css/src/base";
|
||||
|
||||
// Elements
|
||||
@import "../../node_modules/spectre.css/src/typography";
|
||||
@import "../../node_modules/spectre.css/src/asian";
|
||||
@import "../../node_modules/spectre.css/src/tables";
|
||||
@import "../../node_modules/spectre.css/src/buttons";
|
||||
@import "../../node_modules/spectre.css/src/forms";
|
||||
@import "../../node_modules/spectre.css/src/labels";
|
||||
@import "../../node_modules/spectre.css/src/codes";
|
||||
@import "../../node_modules/spectre.css/src/media";
|
||||
|
||||
// Components
|
||||
@import "../../node_modules/spectre.css/src/badges";
|
||||
@import "../../node_modules/spectre.css/src/dropdowns";
|
||||
@import "../../node_modules/spectre.css/src/empty";
|
||||
@import "../../node_modules/spectre.css/src/menus";
|
||||
@import "../../node_modules/spectre.css/src/modals";
|
||||
@import "../../node_modules/spectre.css/src/pagination";
|
||||
@import "../../node_modules/spectre.css/src/tabs";
|
||||
@import "../../node_modules/spectre.css/src/toasts";
|
||||
@import "../../node_modules/spectre.css/src/tooltips";
|
||||
|
||||
// Utility classes
|
||||
@import "../../node_modules/spectre.css/src/animations";
|
||||
@import "../../node_modules/spectre.css/src/utilities";
|
||||
|
||||
// Auto-complete component
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
|
||||
|
||||
/* Spectre overrides / fixes */
|
||||
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
|
||||
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
|
||||
.btn {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Fix radio button sub-pixel size
|
||||
.form-radio .form-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.form-radio input:checked + .form-icon::before {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transform: unset;
|
||||
}
|
||||
|
||||
// Make code work with light and dark theme
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix padding for first menu item
|
||||
ul.menu li:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
// Add border to separate from background in dark mode
|
||||
.modal-container {
|
||||
border: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix modal header to use default color
|
||||
.modal-header {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
143
bookmarks/styles/theme-dark.css
Normal file
143
bookmarks/styles/theme-dark.css
Normal file
@@ -0,0 +1,143 @@
|
||||
@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);
|
||||
|
||||
--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%);
|
||||
|
||||
--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);
|
||||
|
||||
--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%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
|
||||
|
||||
--icon-color: var(--text-color);
|
||||
|
||||
--border-color: var(--contrast-30);
|
||||
--secondary-border-color: var(--contrast-20);
|
||||
|
||||
--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;
|
||||
|
||||
/* 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);
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
: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-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-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);
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
// Import custom variables
|
||||
@import "variables-dark";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
|
||||
/* Dark theme overrides */
|
||||
|
||||
// Buttons
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus ring
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-input:not(:placeholder-shown):invalid,
|
||||
.form-input:not(:placeholder-shown):invalid:focus,
|
||||
.has-error .form-input,
|
||||
.form-input.is-error,
|
||||
.has-error .form-select,
|
||||
.form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: $dt-primary-button-color;
|
||||
}
|
||||
|
||||
.form-radio input:checked + .form-icon::before {
|
||||
background: $light-color;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
30
bookmarks/styles/theme-light.css
Normal file
30
bookmarks/styles/theme-light.css
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "theme/variables.css";
|
||||
@import "theme/_normalize.css";
|
||||
@import "theme/base.css";
|
||||
@import "theme/typography.css";
|
||||
@import "theme/asian.css";
|
||||
@import "theme/tables.css";
|
||||
@import "theme/buttons.css";
|
||||
@import "theme/forms.css";
|
||||
@import "theme/code.css";
|
||||
@import "theme/dropdowns.css";
|
||||
@import "theme/menus.css";
|
||||
@import "theme/badges.css";
|
||||
@import "theme/empty.css";
|
||||
@import "theme/modals.css";
|
||||
@import "theme/pagination.css";
|
||||
@import "theme/tabs.css";
|
||||
@import "theme/toasts.css";
|
||||
@import "theme/autocomplete.css";
|
||||
@import "theme/animations.css";
|
||||
@import "theme/utilities.css";
|
||||
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
@@ -1,12 +0,0 @@
|
||||
// Import custom variables
|
||||
@import "variables-light";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
21
bookmarks/styles/theme/LICENSE
Normal file
21
bookmarks/styles/theme/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 - 2020 Yan Zhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
446
bookmarks/styles/theme/_normalize.css
Normal file
446
bookmarks/styles/theme/_normalize.css
Normal file
@@ -0,0 +1,446 @@
|
||||
/* Manually forked from Normalize.css */
|
||||
/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/**
|
||||
* 1. Change the default font family in all browsers (opinionated).
|
||||
* 2. Correct the line height in all browsers.
|
||||
* 3. Prevent adjustments of font size after orientation changes in
|
||||
* IE on Windows Phone and in iOS.
|
||||
*/
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
html {
|
||||
font-family: sans-serif; /* 1 */
|
||||
-ms-text-size-adjust: 100%; /* 3 */
|
||||
-webkit-text-size-adjust: 100%; /* 3 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
footer,
|
||||
header,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
figcaption,
|
||||
figure,
|
||||
main { /* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct margin in IE 8 (removed).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers. (removed)
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the gray background on active links in IE 10.
|
||||
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent; /* 1 */
|
||||
-webkit-text-decoration-skip: objects; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the outline on focused links when they are also active or hovered
|
||||
* in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
a:active,
|
||||
a:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify default styling of address.
|
||||
*/
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Firefox 39-.
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: var(--mono-font-family); /* 1 (changed) */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font style in Android 4.3-.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct background and color in IE 9-. (Removed)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
font-weight: 400; /* (added) */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
audio,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in iOS 4-7.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10-.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overflow in IE.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers (opinionated).
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 (changed) */
|
||||
font-size: inherit; /* 1 (changed) */
|
||||
line-height: inherit; /* 1 (changed) */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
|
||||
* controls in Android 4.
|
||||
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
html [type="button"], /* 1 */
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule (removed).
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Change the border, margin, and padding in all browsers (opinionated) (changed).
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct display in IE 9-.
|
||||
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
display: inline-block; /* 1 */
|
||||
vertical-align: baseline; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10-.
|
||||
* 2. Remove the padding in IE 10-.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||
*/
|
||||
|
||||
details, /* 1 */
|
||||
menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scripting
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
canvas {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hidden
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10-.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
38
bookmarks/styles/theme/animations.css
Normal file
38
bookmarks/styles/theme/animations.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Animations */
|
||||
@keyframes loading {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
43
bookmarks/styles/theme/asian.css
Normal file
43
bookmarks/styles/theme/asian.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* Optimized for East Asian CJK */
|
||||
html:lang(zh),
|
||||
html:lang(zh-Hans),
|
||||
.lang-zh,
|
||||
.lang-zh-hans {
|
||||
font-family: var(--cjk-zh-hans-font-family);
|
||||
}
|
||||
|
||||
html:lang(zh-Hant),
|
||||
.lang-zh-hant {
|
||||
font-family: var(--cjk-zh-hant-font-family);
|
||||
}
|
||||
|
||||
html:lang(ja),
|
||||
.lang-ja {
|
||||
font-family: var(--cjk-jp-font-family);
|
||||
}
|
||||
|
||||
html:lang(ko),
|
||||
.lang-ko {
|
||||
font-family: var(--cjk-ko-font-family);
|
||||
}
|
||||
|
||||
:lang(zh),
|
||||
:lang(ja),
|
||||
.lang-cjk {
|
||||
& 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;
|
||||
}
|
||||
}
|
55
bookmarks/styles/theme/autocomplete.css
Normal file
55
bookmarks/styles/theme/autocomplete.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Autocomplete */
|
||||
.form-autocomplete {
|
||||
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);
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .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;
|
||||
}
|
||||
}
|
||||
}
|
64
bookmarks/styles/theme/badges.css
Normal file
64
bookmarks/styles/theme/badges.css
Normal file
@@ -0,0 +1,64 @@
|
||||
/* Badges */
|
||||
.badge {
|
||||
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] {
|
||||
&::after {
|
||||
font-size: var(--font-size-sm);
|
||||
height: .9rem;
|
||||
line-height: 1;
|
||||
min-width: .9rem;
|
||||
padding: .1rem .2rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-badge]),
|
||||
&[data-badge=""] {
|
||||
&::after {
|
||||
height: 6px;
|
||||
min-width: 6px;
|
||||
padding: 0;
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Buttons */
|
||||
|
||||
&.btn {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Avatars */
|
||||
|
||||
&.avatar {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 14.64%;
|
||||
right: 14.64%;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: var(--zindex-1);
|
||||
}
|
||||
}
|
||||
}
|
61
bookmarks/styles/theme/base.css
Normal file
61
bookmarks/styles/theme/base.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Base */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
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;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:hover,
|
||||
a:active,
|
||||
a.active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
257
bookmarks/styles/theme/buttons.css
Normal file
257
bookmarks/styles/theme/buttons.css
Normal file
@@ -0,0 +1,257 @@
|
||||
/* 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-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-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 {
|
||||
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);
|
||||
}
|
||||
|
||||
&: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-primary-hover-bg-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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
||||
&.btn-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-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);
|
||||
}
|
||||
|
||||
/* Button Block */
|
||||
|
||||
&.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Button Action */
|
||||
|
||||
&.btn-action {
|
||||
width: var(--control-size);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
&.btn-sm {
|
||||
width: var(--control-size-sm);
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
width: var(--control-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Clear */
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button groups */
|
||||
.btn-group {
|
||||
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 0;
|
||||
}
|
||||
}
|
||||
}
|
30
bookmarks/styles/theme/code.css
Normal file
30
bookmarks/styles/theme/code.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Code */
|
||||
:root {
|
||||
--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%;
|
||||
}
|
||||
|
||||
.code {
|
||||
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%;
|
||||
}
|
||||
}
|
36
bookmarks/styles/theme/dropdowns.css
Normal file
36
bookmarks/styles/theme/dropdowns.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
.menu {
|
||||
animation: fade-in .15s ease 1;
|
||||
display: none;
|
||||
left: 0;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
&.dropdown-right {
|
||||
.menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu,
|
||||
.dropdown-toggle:focus + .menu,
|
||||
.menu:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fix dropdown-toggle border radius in button groups */
|
||||
.btn-group {
|
||||
.dropdown-toggle:nth-last-child(2) {
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
21
bookmarks/styles/theme/empty.css
Normal file
21
bookmarks/styles/theme/empty.css
Normal file
@@ -0,0 +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);
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
.empty-title,
|
||||
.empty-subtitle {
|
||||
margin: var(--layout-spacing) auto;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: var(--layout-spacing-lg);
|
||||
}
|
||||
}
|
515
bookmarks/styles/theme/forms.css
Normal file
515
bookmarks/styles/theme/forms.css
Normal file
@@ -0,0 +1,515 @@
|
||||
/* 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);
|
||||
|
||||
--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;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&:first-of-type {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
legend {
|
||||
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;
|
||||
}
|
||||
|
||||
details summary .form-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
details[open] summary .form-label {
|
||||
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%;
|
||||
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 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-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 types */
|
||||
|
||||
&[type="file"] {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Textarea */
|
||||
textarea.form-input {
|
||||
&,
|
||||
&.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);
|
||||
|
||||
.has-success &,
|
||||
.is-success + & {
|
||||
color: var(--success-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;
|
||||
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);
|
||||
|
||||
& 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));
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
input {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Input checkbox, radio, and switch sizes */
|
||||
|
||||
&.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-checkbox {
|
||||
font-weight: 500;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Switch */
|
||||
.form-switch {
|
||||
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);
|
||||
|
||||
&::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);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Icons */
|
||||
.has-icon-left,
|
||||
.has-icon-right {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-left {
|
||||
& .form-icon {
|
||||
left: var(--border-width);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right {
|
||||
& .form-icon {
|
||||
right: var(--border-width);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Form element: Input groups */
|
||||
.input-group {
|
||||
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;
|
||||
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--success-color);
|
||||
}
|
||||
}
|
||||
|
||||
.has-error &,
|
||||
&.is-error {
|
||||
background: var(--error-color-shade);
|
||||
border-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;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&: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;
|
||||
}
|
||||
}
|
89
bookmarks/styles/theme/menus.css
Normal file
89
bookmarks/styles/theme/menus.css
Normal file
@@ -0,0 +1,89 @@
|
||||
: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);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
||||
&.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);
|
||||
}
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
|
||||
& .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;
|
||||
}
|
||||
}
|
93
bookmarks/styles/theme/modals.css
Normal file
93
bookmarks/styles/theme/modals.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* 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 {
|
||||
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);
|
||||
|
||||
& .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);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-overlay, & .modal-container {
|
||||
animation: fade-out .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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
color: var(--text-color);
|
||||
|
||||
& button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: .85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
61
bookmarks/styles/theme/pagination.css
Normal file
61
bookmarks/styles/theme/pagination.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0;
|
||||
padding: var(--unit-1) 0;
|
||||
|
||||
& .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;
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user