Compare commits

..

59 Commits

Author SHA1 Message Date
Sascha Ißbrücker
d1dd85538b Bump version 2025-01-26 19:19:48 +01:00
dependabot[bot]
c5aab3886e Bump nanoid from 3.3.7 to 3.3.8 in /docs (#962)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:09:02 +01:00
dependabot[bot]
3f2739e5a6 Bump astro from 4.16.3 to 4.16.18 in /docs (#929)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.3 to 4.16.18.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/astro@4.16.18/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.18/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:08:50 +01:00
dependabot[bot]
f1ed89a0ba Bump nanoid from 3.3.7 to 3.3.8 (#928)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:51:40 +01:00
dependabot[bot]
a59a7a777c Bump django from 5.1.1 to 5.1.5 (#947)
Bumps [django](https://github.com/django/django) from 5.1.1 to 5.1.5.
- [Commits](https://github.com/django/django/compare/5.1.1...5.1.5)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:51 +01:00
dependabot[bot]
9a5c535872 Bump vite from 5.4.9 to 5.4.14 in /docs (#953)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.9 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:34 +01:00
Sascha Ißbrücker
e6ebca1436 Add default robots.txt to block crawlers (#959) 2025-01-26 09:58:58 +01:00
Justus Grunow
085d67e9f4 Update community.md (#897)
Added link to iOS App Store for Linkdy.

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:10:16 +01:00
Rostislav
68825444fb Add a rust client library to community.md (#914)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:04:28 +01:00
Sebastien Wains
b2ca16ec9c Update community.md (#949) 2025-01-15 22:02:23 +01:00
Sascha Ißbrücker
649f4154e5 Provide accessible name to radio groups (#945) 2025-01-12 22:10:14 +01:00
Sascha Ißbrücker
d2e8a95e3c Fix menu dropdown focus traps (#944) 2025-01-11 12:44:20 +01:00
Sascha Ißbrücker
c3149409b0 Fix how to for changing font size 2024-10-24 22:24:58 +02:00
Vadim Khitrin
4626fa1c67 docs: Add cosmicding To Community Resources (#892)
* docs: Add cosmicding To Community Resources

* sort alphabetically

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-10-24 22:16:22 +02:00
Dany Marcoux
6548e16baa Add option to disable request logs (#887)
Closes #785
2024-10-17 21:47:56 +02:00
dependabot[bot]
c177de164a Bump astro from 4.15.8 to 4.16.3 in /docs (#884)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.15.8 to 4.16.3.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.3/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-16 21:08:46 +02:00
Xuanli
e9ecad38ac Add serchding to community projects (#880) 2024-10-12 18:13:50 +02:00
Sascha Ißbrücker
621aedd8eb Update donations 2024-10-04 18:43:02 +02:00
Sascha Ißbrücker
4187141ac8 Update CHANGELOG.md 2024-10-03 00:09:51 +02:00
Sascha Ißbrücker
cf0cc32090 Bump version 2024-10-02 22:39:57 +02:00
ixzhao
1f2cf21585 Add LAST_MODIFIED attribute when exporting (#860)
* add LAST_MODIFIED attribute when exporting

* complement test_exporter for LAST_MODIFIED attribute

* parse LAST_MODIFIED attribute when importing

* use bookmark date_added when no modified date is parsed, otherwise use parsed datetime.

* complement test_parser and test_importer for LAST_MODIFIED attribute

* cleanup tests a bit

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-10-02 22:21:08 +02:00
Sascha Ißbrücker
0dd05b9269 Fix permission issue with ublock extension 2024-10-02 21:47:32 +02:00
Sascha Ißbrücker
5cd6d773db Replace uBlock Origin with uBlock Origin Lite (#866) 2024-09-29 14:42:50 +02:00
Sascha Ißbrücker
d4c348cc5a Simplify Docker build (#865) 2024-09-28 16:13:07 +02:00
Sascha Ißbrücker
791a5c73ca Do not escape valid characters in custom CSS (#863) 2024-09-28 11:17:48 +02:00
Sascha Ißbrücker
ebed0c050d Add edit link to docs pages 2024-09-25 17:34:19 +02:00
Sascha Ißbrücker
f4dd2b53b5 Fix select dropdown menu background in dark theme (#858) 2024-09-24 21:42:52 +02:00
dependabot[bot]
b53fe09c39 Bump rollup from 4.21.3 to 4.22.4 in /docs (#856)
Bumps [rollup](https://github.com/rollup/rollup) from 4.21.3 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.21.3...v4.22.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 19:11:08 +02:00
dependabot[bot]
ff88e726cc Bump rollup from 4.13.0 to 4.22.4 (#851)
Bumps [rollup](https://github.com/rollup/rollup) from 4.13.0 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.13.0...v4.22.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:28:21 +02:00
Sascha Ißbrücker
52400feacf Improve error handling for auto tagging (#855) 2024-09-24 17:26:01 +02:00
Sascha Ißbrücker
c93709b549 Fix jumping details modal on back navigation (#854) 2024-09-24 17:09:17 +02:00
Sascha Ißbrücker
ba904ed191 Fix header backdrop on iOS 2024-09-24 16:29:05 +02:00
Sascha Ißbrücker
d1f81fee0e Prevent duplicates when editing (#853)
* prevent creating duplicate URLs on edit

* Prevent duplicates when editing
2024-09-24 13:13:32 +02:00
Sascha Ißbrücker
7b405c054d Do not clear fields in POST requests (API behavior change) (#852) 2024-09-24 11:37:50 +02:00
Vlad-Stefan Harbuz
23ad52f75d Fix header.svg text (#850) 2024-09-23 23:28:37 +02:00
Sascha Ißbrücker
c3a2305a5f Return client error status code for invalid form submissions (#849)
* Returns client error status code for invalid form submissions

* fix flaky test
2024-09-23 20:30:49 +02:00
Sascha Ißbrücker
d4006026db Fix preview image spacing 2024-09-23 19:30:42 +02:00
Sascha Ißbrücker
70bdf88791 Update CHANGELOG.md 2024-09-23 17:44:06 +02:00
Sascha Ißbrücker
93e2832a89 Bump version 2024-09-23 16:28:17 +02:00
Sascha Ißbrücker
f5708594a7 Add basic fail2ban support (#847) 2024-09-23 16:20:55 +02:00
Sascha Ißbrücker
67f237c1de Update docs link 2024-09-23 15:58:21 +02:00
Sascha Ißbrücker
95f489ea48 Format CSS with prettier 2024-09-23 11:04:36 +02:00
Sascha Ißbrücker
ed57da3c99 Add clear buttons in bookmark form (#846) 2024-09-23 11:02:30 +02:00
Sascha Ißbrücker
c5c5949d20 Do not overwrite provided title and description (#845) 2024-09-22 21:43:03 +02:00
Rostislav
f4e66c1ff1 fix a broken link to options documentation (#844) 2024-09-22 16:05:04 +02:00
Sascha Ißbrücker
fe7ddbe645 Allow bookmarks to have empty title and description (#843)
* add migration for merging fields

* remove usage of website title and description

* keep empty website title and description in API for compatibility

* restore scraping in API and add option for disabling it

* document API scraping behavior

* remove deprecated fields from API docs

* improve form layout

* cleanup migration

* cleanup website loader

* update tests
2024-09-22 07:52:00 +02:00
Sascha Ißbrücker
afa57aa10b Show placeholder if there is no preview image (#842)
* Show placeholder if there is no preview image

* add test
2024-09-20 08:56:17 +02:00
Sascha Ißbrücker
b4108c9a56 bump python version in CI 2024-09-19 19:38:03 +02:00
Sascha Ißbrücker
6cf5fb396a remove deprecated API usage 2024-09-19 19:29:19 +02:00
Sascha Ißbrücker
3d8866c7bc Bump dependencies (#841) 2024-09-19 19:15:51 +02:00
voltagex
8544137a31 Use HTTPS repository link for devcontainer (#837)
* Use HTTPS repository link for devcontainer

The SSH repository link means you need to be set up with a key, have already accepted the github.com host key.

Cloning via HTTPS requires less steps.

* Update devcontainer.json

* Update requirements.txt
2024-09-19 15:46:52 +02:00
dependabot[bot]
baa3d5596d Bump path-to-regexp and astro in /docs (#840)
Removes [path-to-regexp](https://github.com/pillarjs/path-to-regexp). It's no longer used after updating ancestor dependency [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro). These dependencies need to be updated together.


Removes `path-to-regexp`

Updates `astro` from 4.15.6 to 4.15.8
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.15.8/packages/astro)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
- dependency-name: astro
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 15:42:12 +02:00
voltagex
f79c24453c Bump requests version to 3.23.3 (#839)
This avoids the yanked 3.23.0 version and allows pip-compile to work without --upgrade again.
2024-09-19 15:37:01 +02:00
Sascha Ißbrücker
f3c1101746 Improve docs 2024-09-19 10:28:19 +02:00
Piero Lescano
ceceb56164 Add go-linkding to community projects (#836) 2024-09-19 06:12:19 +02:00
Sascha Ißbrücker
450980a8d4 Add configuration options for pagination (#835) 2024-09-18 23:14:19 +02:00
Sascha Ißbrücker
2aab2813f4 fix community links 2024-09-17 15:42:18 +02:00
Sascha Ißbrücker
0e488b7ce3 Add documentation website (#833)
* test frontmatter rendering

* restructure files

* add docs website

* move postcss config

* revert postcss config

* update readme

* update logo

* fix internal links
2024-09-17 15:33:53 +02:00
Sascha Ißbrücker
53e4aeb1c1 Update CHANGELOG.md 2024-09-16 13:37:20 +02:00
152 changed files with 12313 additions and 3110 deletions

View File

@@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:

2
.gitignore vendored
View File

@@ -192,7 +192,7 @@ typings/
# Database file
/data
# ublock + chromium
/uBlock0.chromium
/uBOLite.chromium.mv3
/chromium-profile
# direnv
/.direnv

View File

@@ -1,5 +1,69 @@
# Changelog
## v1.36.0 (02/10/2024)
### What's Changed
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
### New Contributors
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
---
## v1.35.0 (23/09/2024)
### What's Changed
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
### New Contributors
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
---
## v1.34.0 (16/09/2024)
### What's Changed
* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825
* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829
* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826
### New Contributors
* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0
---
## v1.33.0 (14/09/2024)
### What's Changed

View File

@@ -13,3 +13,4 @@ format:
black bookmarks
black siteroot
npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write

232
README.md
View File

@@ -1,25 +1,11 @@
<div align="center">
<br>
<a href="https://github.com/sissbruecker/linkding">
<img src="docs/header.svg" height="50">
<img src="assets/header.svg" height="50">
</a>
<br>
</div>
## Overview
- [Introduction](#introduction)
- [Installation](#installation)
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
- [Community](#community)
- [Acknowledgements + Donations](#acknowledgements--donations)
- [Development](#development)
## Introduction
linkding is a bookmark manager that you can host yourself.
@@ -49,219 +35,33 @@ The name comes from:
**Screenshot:**
![Screenshot](/docs/linkding-screenshot.png?raw=true "Screenshot")
![Screenshot](/docs/public/linkding-screenshot.png?raw=true "Screenshot")
## Installation
## Getting Started
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
linkding uses an SQLite database by default.
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
### Using Docker
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
<table>
<thead>
<tr>
<th>Tag</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>latest</code></td>
<td>Provides the basic functionality of linkding</td>
</tr>
<tr>
<td><code>latest-plus</code></td>
<td>
Includes feature for archiving websites as HTML snapshots
<ul>
<li>Significantly larger image size as it includes a Chromium installation</li>
<li>Requires more runtime memory to run Chromium</li>
<li>Requires more disk space for storing HTML snapshots</li>
</ul>
</td>
</tr>
<tr>
<td><code>latest-alpine</code></td>
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
</tr>
<tr>
<td><code>latest-plus-alpine</code></td>
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
</tr>
</tbody>
</table>
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
```shell
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
```
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
### Using Docker Compose
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
```shell
docker-compose up -d
```
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
### User Setup
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
**Docker**
```shell
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
```
**Docker Compose**
```shell
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
```
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
### Reverse Proxy Setup
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
<details>
<summary>Apache</summary>
Apache2 does not change the headers by default, and should not
need additional configuration.
An example virtual host that proxies to linkding might look like:
```
<VirtualHost *:9100>
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
ProxyPass / http://linkding:9090/
ProxyPassReverse / http://linkding:9090/
</VirtualHost>
```
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
</details>
<details>
<summary>Caddy 2</summary>
Caddy does not change the headers by default, and should not need any further configuration.
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
</details>
<details>
<summary>Nginx</summary>
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
```
location /linkding {
...
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
</details>
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
### Managed Hosting Options
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
The following links help you to get started with linkding:
- [Install linkding on your own server](https://linkding.link/installation) or [check managed hosting options](https://linkding.link/managed-hosting)
- [Install the browser extension](https://linkding.link/browser-extension)
- [Check out community projects](https://linkding.link/community), which include mobile apps, browser extensions, libraries and more
## Documentation
| Document | Description |
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
The full documentation is now available at [linkding.link](https://linkding.link/).
## Browser Extension
If you want to contribute to the documentation, you can find the source files in the `docs` folder.
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
If you want to contribute a community project, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md).
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
## Contributing
## Community
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
## Acknowledgements + Donations
### PikaPods
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
See the table below for a list of donations.
| Source | Description | Amount | Donated to |
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
### JetBrains
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
Small improvements, bugfixes and documentation improvements are always welcome. If you want to contribute a larger feature, consider opening an issue first to discuss it. I may choose to ignore PRs for features that don't align with the project's goals or that I don't want to maintain.
## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3.10
- Python 3.12
- Node.js
### Setup
@@ -305,7 +105,7 @@ The frontend is now available under http://localhost:8000
Run all tests with pytest:
```
pytest
make test
```
### Formatting
@@ -317,7 +117,7 @@ make format
### DevContainers
This repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
This repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
Once checked out, only the following commands are required to get started:

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

41
assets/header.svg Normal file
View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 2126 591" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(1.18075,0,0,1.18075,-1265.31,-1395.82)">
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
</g>
<g transform="matrix(0.823127,0,0,0.823127,-786.171,-888.198)">
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
</g>
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
</g>
</g>
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
<g transform="matrix(50,0,0,50,770.835,299.13)">
<rect x="0.064" y="-0.716" width="0.088" height="0.716" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,782.693,299.13)">
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,794.552,299.13)">
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,823.109,299.13)">
<path d="M0.066,-0L0.066,-0.716L0.154,-0.716L0.154,-0.308L0.362,-0.519L0.476,-0.519L0.278,-0.326L0.496,-0L0.388,-0L0.216,-0.265L0.154,-0.206L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,848.859,299.13)">
<path d="M0.402,-0L0.402,-0.065C0.369,-0.014 0.321,0.012 0.257,0.012C0.216,0.012 0.178,0 0.143,-0.022C0.109,-0.045 0.082,-0.077 0.063,-0.118C0.044,-0.159 0.034,-0.206 0.034,-0.259C0.034,-0.311 0.043,-0.357 0.06,-0.399C0.077,-0.442 0.103,-0.474 0.138,-0.497C0.172,-0.519 0.211,-0.53 0.253,-0.53C0.285,-0.53 0.313,-0.524 0.337,-0.51C0.361,-0.497 0.381,-0.48 0.396,-0.459L0.396,-0.716L0.484,-0.716L0.484,-0L0.402,-0ZM0.125,-0.259C0.125,-0.192 0.139,-0.143 0.167,-0.11C0.194,-0.077 0.228,-0.061 0.266,-0.061C0.304,-0.061 0.337,-0.076 0.363,-0.107C0.39,-0.139 0.404,-0.187 0.404,-0.251C0.404,-0.322 0.39,-0.375 0.363,-0.408C0.335,-0.441 0.302,-0.458 0.262,-0.458C0.223,-0.458 0.19,-0.442 0.164,-0.41C0.138,-0.378 0.125,-0.327 0.125,-0.259Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,877.417,299.13)">
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,889.275,299.13)">
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,917.833,299.13)">
<path d="M0.05,0.043L0.135,0.056C0.139,0.082 0.149,0.101 0.165,0.113C0.187,0.13 0.217,0.138 0.254,0.138C0.295,0.138 0.326,0.13 0.349,0.113C0.371,0.097 0.386,0.074 0.394,0.045C0.398,0.027 0.4,-0.011 0.4,-0.068C0.361,-0.023 0.314,-0 0.256,-0C0.185,-0 0.13,-0.026 0.091,-0.077C0.052,-0.129 0.032,-0.19 0.032,-0.262C0.032,-0.312 0.041,-0.357 0.059,-0.399C0.077,-0.441 0.103,-0.473 0.137,-0.496C0.171,-0.519 0.211,-0.53 0.257,-0.53C0.318,-0.53 0.368,-0.506 0.408,-0.456L0.408,-0.519L0.489,-0.519L0.489,-0.07C0.489,0.01 0.481,0.068 0.464,0.101C0.448,0.135 0.422,0.162 0.386,0.181C0.351,0.201 0.307,0.21 0.255,0.21C0.193,0.21 0.143,0.196 0.105,0.168C0.067,0.141 0.049,0.099 0.05,0.043ZM0.123,-0.269C0.123,-0.201 0.136,-0.151 0.163,-0.12C0.19,-0.088 0.224,-0.073 0.265,-0.073C0.305,-0.073 0.339,-0.088 0.366,-0.119C0.394,-0.15 0.407,-0.199 0.407,-0.266C0.407,-0.329 0.393,-0.377 0.365,-0.409C0.337,-0.441 0.303,-0.458 0.263,-0.458C0.224,-0.458 0.191,-0.442 0.164,-0.41C0.136,-0.378 0.123,-0.331 0.123,-0.269Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1 +1,17 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 450 450" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(1,0,0,1,-70.3466,-70.3466)">
<g transform="matrix(1.18075,0,0,1.18075,-1257.39,-1386.74)">
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
</g>
<g transform="matrix(0.793058,0,0,0.793058,-739.034,-836.215)">
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
</g>
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -56,7 +56,12 @@ class BookmarkViewSet(
return Bookmark.objects.all().filter(owner=user)
def get_serializer_context(self):
return {"request": self.request, "user": self.request.user}
disable_scraping = "disable_scraping" in self.request.GET
return {
"request": self.request,
"user": self.request.user,
"disable_scraping": disable_scraping,
}
@action(methods=["get"], detail=False)
def archived(self, request):
@@ -101,16 +106,7 @@ class BookmarkViewSet(
self.get_serializer(bookmark).data if bookmark else None
)
# Either return metadata from existing bookmark, or scrape from URL
if bookmark:
metadata = WebsiteMetadata(
url,
bookmark.website_title,
bookmark.website_description,
None,
)
else:
metadata = website_loader.load_website_metadata(url)
metadata = website_loader.load_website_metadata(url)
# Return tags that would be automatically applied to the bookmark
profile = request.user.profile
@@ -120,7 +116,7 @@ class BookmarkViewSet(
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
f"Failed to auto-tag bookmark. url={url}",
exc_info=e,
)

View File

@@ -4,7 +4,11 @@ from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
enhance_with_website_metadata,
)
from bookmarks.services.tags import get_or_create_tag
@@ -29,8 +33,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
"title",
"description",
"notes",
"website_title",
"website_description",
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
@@ -40,29 +42,29 @@ class BookmarkSerializer(serializers.ModelSerializer):
"tag_names",
"date_added",
"date_modified",
]
read_only_fields = [
"website_title",
"website_description",
]
read_only_fields = [
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
"tag_names",
"date_added",
"date_modified",
"website_title",
"website_description",
]
list_serializer_class = BookmarkListSerializer
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default="")
description = serializers.CharField(required=False, allow_blank=True, default="")
notes = serializers.CharField(required=False, allow_blank=True, default="")
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
shared = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
# Custom tag_names field to allow passing a list of tag names to create/update
tag_names = TagListField(required=False)
# Custom fields to return URLs for favicon and preview image
favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = serializers.SerializerMethodField()
website_description = serializers.SerializerMethodField()
def get_favicon_url(self, obj: Bookmark):
if not obj.favicon_file:
@@ -80,31 +82,53 @@ class BookmarkSerializer(serializers.ModelSerializer):
preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url
def get_website_title(self, obj: Bookmark):
return None
def get_website_description(self, obj: Bookmark):
return None
def create(self, validated_data):
bookmark = Bookmark()
bookmark.url = validated_data["url"]
bookmark.title = validated_data["title"]
bookmark.description = validated_data["description"]
bookmark.notes = validated_data["notes"]
bookmark.is_archived = validated_data["is_archived"]
bookmark.unread = validated_data["unread"]
bookmark.shared = validated_data["shared"]
tag_string = build_tag_string(validated_data["tag_names"])
return create_bookmark(bookmark, tag_string, self.context["user"])
tag_names = validated_data.pop("tag_names", [])
tag_string = build_tag_string(tag_names)
bookmark = Bookmark(**validated_data)
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
# Unless scraping is explicitly disabled, enhance bookmark with website
# metadata to preserve backwards compatibility with clients that expect
# title and description to be populated automatically when left empty
if not self.context.get("disable_scraping", False):
enhance_with_website_metadata(saved_bookmark)
return saved_bookmark
def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
for key in ["url", "title", "description", "notes", "unread", "shared"]:
if key in validated_data:
setattr(instance, key, validated_data[key])
tag_names = validated_data.pop("tag_names", instance.tag_names)
tag_string = build_tag_string(tag_names)
# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if "tag_names" in validated_data:
tag_string = build_tag_string(validated_data["tag_names"])
for field_name, field in self.fields.items():
if not field.read_only and field_name in validated_data:
setattr(instance, field_name, validated_data[field_name])
return update_bookmark(instance, tag_string, self.context["user"])
def validate(self, attrs):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead. When editing a bookmark,
# there is no assumption that it would update a different bookmark if
# the URL is a duplicate, so raise a validation error in that case.
if self.instance and "url" in attrs:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise serializers.ValidationError(
{"url": "A bookmark with this URL already exists."}
)
return attrs
class TagSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -135,6 +135,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal = self.open_details_modal(bookmark)
# Wait for confirm button to be initialized
self.page.wait_for_timeout(1000)
# Delete bookmark, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
details_modal.get_by_text("Delete...").click()

View File

@@ -1,109 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark(
title="Existing title",
description="Existing description",
notes="Existing notes",
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
website_title="Existing website title",
website_description="Existing website description",
unread=True,
)
tag_names = " ".join(existing_bookmark.tag_names)
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:new"))
# Enter bookmarked URL
page.get_by_label("URL").fill(existing_bookmark.url)
# Already bookmarked hint should be visible
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
# Form should be pre-filled with data from existing bookmark
self.assertEqual(
existing_bookmark.title, page.get_by_label("Title").input_value()
)
self.assertEqual(
existing_bookmark.description,
page.get_by_label("Description").input_value(),
)
self.assertEqual(
existing_bookmark.notes, page.get_by_label("Notes").input_value()
)
self.assertEqual(
existing_bookmark.website_title,
page.get_by_label("Title").get_attribute("placeholder"),
)
self.assertEqual(
existing_bookmark.website_description,
page.get_by_label("Description").get_attribute("placeholder"),
)
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
# Enter non-bookmarked URL
page.get_by_label("URL").fill("https://example.com/unknown")
# Already bookmarked hint should be hidden
page.get_by_text("This URL is already bookmarked.").wait_for(
state="hidden", timeout=2000
)
browser.close()
def test_edit_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(
self.live_server_url + reverse("bookmarks:edit", args=[bookmark.id])
)
page.wait_for_timeout(timeout=1000)
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
def test_enter_url_of_existing_bookmark_should_show_notes(self):
bookmark = self.setup_bookmark(
notes="Existing notes", description="Existing description"
)
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:new"))
details = page.locator("details.notes")
expect(details).not_to_have_attribute("open", value="")
page.get_by_label("URL").fill(bookmark.url)
expect(details).to_have_attribute("open", value="")
def test_create_should_preview_auto_tags(self):
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = "github.com dev github"
profile.save()
with sync_playwright() as p:
# Open page with URL that should have auto tags
browser = self.setup_browser(p)
page = browser.new_page()
url = self.live_server_url + reverse("bookmarks:new")
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
page.goto(url)
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
expect(auto_tags_hint).to_be_visible()
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
# Change to URL without auto tags
page.get_by_label("URL").fill("https://example.com")
expect(auto_tags_hint).to_be_hidden()

View File

@@ -0,0 +1,65 @@
from unittest.mock import patch
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.services import website_loader
mock_website_metadata = website_loader.WebsiteMetadata(
url="https://example.com",
title="Example Domain",
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
preview_image=None,
)
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def setUp(self) -> None:
super().setUp()
self.website_loader_patch = patch.object(
website_loader, "load_website_metadata", return_value=mock_website_metadata
)
self.website_loader_patch.start()
def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop()
def test_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
page = self.open(reverse("bookmarks:edit", args=[bookmark.id]), p)
page.wait_for_timeout(timeout=1000)
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
def test_should_not_prefill_title_and_description(self):
bookmark = self.setup_bookmark(
title="Initial title", description="Initial description"
)
with sync_playwright() as p:
page = self.open(reverse("bookmarks:edit", args=[bookmark.id]), p)
page.wait_for_timeout(timeout=1000)
title = page.get_by_label("Title")
description = page.get_by_label("Description")
expect(title).to_have_value(bookmark.title)
expect(description).to_have_value(bookmark.description)
def test_enter_url_should_not_prefill_title_and_description(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
page = self.open(reverse("bookmarks:edit", args=[bookmark.id]), p)
page.get_by_label("URL").fill("https://example.com")
page.wait_for_timeout(timeout=1000)
title = page.get_by_label("Title")
description = page.get_by_label("Description")
expect(title).to_have_value(bookmark.title)
expect(description).to_have_value(bookmark.description)

View File

@@ -0,0 +1,166 @@
from unittest.mock import patch
from urllib.parse import quote
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.services import website_loader
mock_website_metadata = website_loader.WebsiteMetadata(
url="https://example.com",
title="Example Domain",
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
preview_image=None,
)
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def setUp(self) -> None:
super().setUp()
self.website_loader_patch = patch.object(
website_loader, "load_website_metadata", return_value=mock_website_metadata
)
self.website_loader_patch.start()
def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop()
def test_enter_url_prefills_title_and_description(self):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
url.fill("https://example.com")
expect(title).to_have_value("Example Domain")
expect(description).to_have_value(
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
)
def test_enter_url_does_not_overwrite_modified_title_and_description(self):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
title.fill("Modified title")
description.fill("Modified description")
url.fill("https://example.com")
page.wait_for_timeout(timeout=1000)
expect(title).to_have_value("Modified title")
expect(description).to_have_value("Modified description")
def test_with_initial_url_prefills_title_and_description(self):
with sync_playwright() as p:
page_url = reverse("bookmarks:new") + f"?url={quote('https://example.com')}"
page = self.open(page_url, p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
page.wait_for_timeout(timeout=1000)
expect(url).to_have_value("https://example.com")
expect(title).to_have_value("Example Domain")
expect(description).to_have_value(
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
)
def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
self,
):
with sync_playwright() as p:
page_url = (
reverse("bookmarks:new")
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
)
page = self.open(page_url, p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
page.wait_for_timeout(timeout=1000)
expect(url).to_have_value("https://example.com")
expect(title).to_have_value("Initial title")
expect(description).to_have_value("Initial description")
def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark(
title="Existing title",
description="Existing description",
notes="Existing notes",
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
unread=True,
)
tag_names = " ".join(existing_bookmark.tag_names)
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
# Enter bookmarked URL
page.get_by_label("URL").fill(existing_bookmark.url)
# Already bookmarked hint should be visible
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
# Form should be pre-filled with data from existing bookmark
self.assertEqual(
existing_bookmark.title, page.get_by_label("Title").input_value()
)
self.assertEqual(
existing_bookmark.description,
page.get_by_label("Description").input_value(),
)
self.assertEqual(
existing_bookmark.notes, page.get_by_label("Notes").input_value()
)
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
# Enter non-bookmarked URL
page.get_by_label("URL").fill("https://example.com/unknown")
# Already bookmarked hint should be hidden
page.get_by_text("This URL is already bookmarked.").wait_for(
state="hidden", timeout=2000
)
def test_enter_url_of_existing_bookmark_should_show_notes(self):
bookmark = self.setup_bookmark(
notes="Existing notes", description="Existing description"
)
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
details = page.locator("details.notes")
expect(details).not_to_have_attribute("open", value="")
page.get_by_label("URL").fill(bookmark.url)
expect(details).to_have_attribute("open", value="")
def test_create_should_preview_auto_tags(self):
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = "github.com dev github"
profile.save()
with sync_playwright() as p:
# Open page with URL that should have auto tags
url = (
reverse("bookmarks:new")
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
)
page = self.open(url, p)
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()

View File

@@ -0,0 +1,42 @@
import { Behavior, registerBehavior } from "./index";
class ClearButtonBehavior extends Behavior {
constructor(element) {
super(element);
this.field = document.getElementById(element.dataset.for);
if (!this.field) {
console.error(`Field with ID ${element.dataset.for} not found`);
return;
}
this.update = this.update.bind(this);
this.clear = this.clear.bind(this);
this.element.addEventListener("click", this.clear);
this.field.addEventListener("input", this.update);
this.field.addEventListener("value-changed", this.update);
this.update();
}
destroy() {
if (!this.field) {
return;
}
this.element.removeEventListener("click", this.clear);
this.field.removeEventListener("input", this.update);
this.field.removeEventListener("value-changed", this.update);
}
update() {
this.element.style.display = this.field.value ? "inline-flex" : "none";
}
clear() {
this.field.value = "";
this.field.focus();
this.update();
}
}
registerBehavior("ld-clear-button", ClearButtonBehavior);

View File

@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
this.opened = false;
this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
// Prevent opening the dropdown automatically on focus, so that it only
// opens on click then JS is enabled
this.element.style.setProperty("--dropdown-focus-display", "none");
this.element.addEventListener("keydown", this.onEscape);
this.element.addEventListener("focusout", this.onFocusOut);
this.toggle = element.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick);
}
destroy() {
this.close();
this.toggle.removeEventListener("click", this.onClick);
this.element.removeEventListener("keydown", this.onEscape);
this.element.removeEventListener("focusout", this.onFocusOut);
}
open() {
this.opened = true;
this.element.classList.add("active");
this.toggle.setAttribute("aria-expanded", "true");
document.addEventListener("click", this.onOutsideClick);
}
close() {
this.opened = false;
this.element.classList.remove("active");
this.toggle.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onOutsideClick);
}
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
this.close();
}
}
onEscape(event) {
if (event.key === "Escape" && this.opened) {
event.preventDefault();
this.close();
this.toggle.focus();
}
}
onFocusOut(event) {
if (!this.element.contains(event.relatedTarget)) {
this.close();
}
}
}
registerBehavior("ld-dropdown", DropdownBehavior);

View File

@@ -16,7 +16,24 @@ const mutationObserver = new MutationObserver((mutations) => {
});
});
document.addEventListener("turbo:load", () => {
// Update behaviors on Turbo events
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
// - turbo:render: after page navigation, including back/forward, and failed form submissions
// - turbo:before-cache: before page navigation, reset DOM before caching
document.addEventListener(
"turbo:load",
() => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
applyBehaviors(document.body);
},
{ once: true },
);
document.addEventListener("turbo:render", () => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
@@ -41,7 +58,6 @@ Behavior.interacting = false;
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
applyBehaviors(document, [name]);
}
export function applyBehaviors(container, behaviorNames = null) {

View File

@@ -122,7 +122,7 @@
}
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const fullLabel = bookmark.title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',

View File

@@ -1,6 +1,7 @@
import "@hotwired/turbo";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/clear-button";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.0.8 on 2024-09-18 20:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="items_per_page",
field=models.IntegerField(
default=30, validators=[django.core.validators.MinValueValidator(10)]
),
),
migrations.AddField(
model_name="userprofile",
name="sticky_pagination",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.1.1 on 2024-09-21 08:13
from django.db import migrations
from django.db.models import Q
from django.db.models.expressions import RawSQL
from bookmarks.models import Bookmark
def forwards(apps, schema_editor):
Bookmark.objects.filter(
Q(title__isnull=True) | Q(title__exact=""),
).extra(
where=["website_title IS NOT NULL"]
).update(title=RawSQL("website_title", ()))
Bookmark.objects.filter(
Q(description__isnull=True) | Q(description__exact=""),
).extra(where=["website_description IS NOT NULL"]).update(
description=RawSQL("website_description", ())
)
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0040_userprofile_items_per_page_and_more"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-09-28 08:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0041_merge_metadata"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="custom_css_hash",
field=models.CharField(blank=True, max_length=32),
),
]

View File

@@ -1,12 +1,14 @@
import binascii
import hashlib
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.core.validators import MinValueValidator
from django.db import models
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@@ -55,7 +57,9 @@ class Bookmark(models.Model):
title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True)
notes = models.TextField(blank=True)
# Obsolete field, kept to not remove column when generating migrations
website_title = models.CharField(max_length=512, blank=True, null=True)
# Obsolete field, kept to not remove column when generating migrations
website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
favicon_file = models.CharField(max_length=512, blank=True)
@@ -73,14 +77,12 @@ class Bookmark(models.Model):
def resolved_title(self):
if self.title:
return self.title
elif self.website_title:
return self.website_title
else:
return self.url
@property
def resolved_description(self):
return self.website_description if not self.description else self.description
return self.description
@property
def tag_names(self):
@@ -140,14 +142,9 @@ class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description in form as we fill these automatically if they are empty
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
# Include website title and description as hidden field as they only provide info when editing bookmarks
website_title = forms.CharField(
max_length=512, required=False, widget=forms.HiddenInput()
)
website_description = forms.CharField(required=False, widget=forms.HiddenInput())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
@@ -161,8 +158,6 @@ class BookmarkForm(forms.ModelForm):
"title",
"description",
"notes",
"website_title",
"website_description",
"unread",
"shared",
"auto_close",
@@ -174,6 +169,24 @@ class BookmarkForm(forms.ModelForm):
self.instance and self.instance.notes
)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
class BookmarkSearch:
SORT_ADDED_ASC = "added_asc"
@@ -418,10 +431,24 @@ class UserProfile(models.Model):
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
custom_css = models.TextField(blank=True, null=False)
custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
auto_tagging_rules = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False)
items_per_page = models.IntegerField(
null=False, default=30, validators=[MinValueValidator(10)]
)
sticky_pagination = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
self.custom_css_hash = hashlib.md5(
self.custom_css.encode("utf-8")
).hexdigest()
else:
self.custom_css_hash = ""
super().save(*args, **kwargs)
class UserProfileForm(forms.ModelForm):
@@ -450,6 +477,8 @@ class UserProfileForm(forms.ModelForm):
"default_mark_unread",
"custom_css",
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
]

View File

@@ -53,8 +53,6 @@ def _base_bookmarks_query(
Q(title__icontains=term)
| Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(website_title__icontains=term)
| Q(website_description__icontains=term)
| Q(url__icontains=term)
)
@@ -87,13 +85,7 @@ def _base_bookmarks_query(
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
query_set = query_set.filter(shared=False)
# Sort by date added
if search.sort == BookmarkSearch.SORT_ADDED_ASC:
query_set = query_set.order_by("date_added")
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
query_set = query_set.order_by("-date_added")
# Sort by title
# Sort
if (
search.sort == BookmarkSearch.SORT_TITLE_ASC
or search.sort == BookmarkSearch.SORT_TITLE_DESC
@@ -103,10 +95,6 @@ def _base_bookmarks_query(
query_set = query_set.annotate(
effective_title=Case(
When(Q(title__isnull=False) & ~Q(title__exact=""), then=Lower("title")),
When(
Q(website_title__isnull=False) & ~Q(website_title__exact=""),
then=Lower("website_title"),
),
default=Lower("url"),
output_field=CharField(),
)
@@ -124,6 +112,11 @@ def _base_bookmarks_query(
query_set = query_set.order_by(order_field)
elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
query_set = query_set.order_by(order_field).reverse()
elif search.sort == BookmarkSearch.SORT_ADDED_ASC:
query_set = query_set.order_by("date_added")
else:
# Sort by date added, descending by default
query_set = query_set.order_by("-date_added")
return query_set

View File

@@ -7,6 +7,9 @@ def get_tags(script: str, url: str):
parsed_url = urlparse(url.lower())
result = set()
if not parsed_url.hostname:
return result
for line in script.lower().split("\n"):
if "#" in line:
i = line.index("#")

View File

@@ -26,8 +26,6 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
_merge_bookmark_data(bookmark, existing_bookmark)
return update_bookmark(existing_bookmark, tag_string, current_user)
# Update website info
_update_website_metadata(bookmark)
# Set currently logged in user as owner
bookmark.owner = current_user
# Set dates
@@ -67,13 +65,21 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed
_update_website_metadata(bookmark)
bookmark.save()
return bookmark
def enhance_with_website_metadata(bookmark: Bookmark):
metadata = website_loader.load_website_metadata(bookmark.url)
if not bookmark.title:
bookmark.title = metadata.title or ""
if not bookmark.description:
bookmark.description = metadata.description or ""
bookmark.save()
def archive_bookmark(bookmark: Bookmark):
bookmark.is_archived = True
bookmark.date_modified = timezone.now()
@@ -235,12 +241,6 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.shared = from_bookmark.shared
def _update_website_metadata(bookmark: Bookmark):
metadata = website_loader.load_website_metadata(bookmark.url)
bookmark.website_title = metadata.title
bookmark.website_description = metadata.description
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string)

View File

@@ -40,9 +40,10 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
toread = "1" if bookmark.unread else "0"
private = "0" if bookmark.shared else "1"
added = int(bookmark.date_added.timestamp())
modified = int(bookmark.date_modified.timestamp())
doc.append(
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
f'<DT><A HREF="{url}" ADD_DATE="{added}" LAST_MODIFIED="{modified}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
)
if desc:

View File

@@ -231,7 +231,10 @@ def _copy_bookmark_data(
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
if netscape_bookmark.date_modified:
bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)
else:
bookmark.date_modified = bookmark.date_added
bookmark.unread = netscape_bookmark.to_read
if netscape_bookmark.title:
bookmark.title = netscape_bookmark.title

View File

@@ -12,6 +12,7 @@ class NetscapeBookmark:
description: str
notes: str
date_added: str
date_modified: str
tag_names: List[str]
to_read: bool
private: bool
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
self.bookmark = None
self.href = ""
self.add_date = ""
self.last_modified = ""
self.tags = ""
self.title = ""
self.description = ""
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
description="",
notes="",
date_added=self.add_date,
date_modified=self.last_modified,
tag_names=tag_names,
to_read=self.toread == "1",
# Mark as private by default, also when attribute is not specified
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
self.bookmark = None
self.href = ""
self.add_date = ""
self.last_modified = ""
self.tags = ""
self.title = ""
self.description = ""

View File

@@ -14,8 +14,8 @@ logger = logging.getLogger(__name__)
@dataclass
class WebsiteMetadata:
url: str
title: str
description: str
title: str | None
description: str | None
preview_image: str | None
def to_dict(self):
@@ -43,7 +43,8 @@ def load_website_metadata(url: str):
start = timezone.now()
soup = BeautifulSoup(page_text, "html.parser")
title = soup.title.string.strip() if soup.title is not None else None
if soup.title and soup.title.string:
title = soup.title.string.strip()
description_tag = soup.find("meta", attrs={"name": "description"})
description = (
description_tag["content"].strip()

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -12,7 +12,8 @@
gap: var(--unit-2);
}
& a.weblink img, & a.weblink svg {
& a.weblink img,
& a.weblink svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
@@ -27,7 +28,7 @@
}
& .preview-image {
margin: var(--unit-4 0);
margin: var(--unit-4) 0;
img {
max-width: 100%;
@@ -110,7 +111,8 @@
gap: var(--unit-2);
}
& .status .form-group, .status .form-switch {
& .status .form-group,
.status .form-switch {
margin: 0;
}

View File

@@ -6,32 +6,23 @@
}
.bookmarks-form {
& .btn.btn-link.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
--btn-icon-color: var(--tertiary-text-color);
& > svg {
width: 20px;
height: 20px;
}
}
& .has-icon-right > input, & .has-icon-right > textarea {
& .has-icon-right > input,
& .has-icon-right > textarea {
padding-right: 30px;
}
& .has-icon-right > input:placeholder-shown ~ .btn.form-icon,
& .has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
& .form-icon.loading {
visibility: hidden;
}
& .form-group .clear-button {
display: none;
padding: 0;
border: none;
height: auto;
font-size: var(--font-size-sm);
}
& .form-input-hint.bookmark-exists {
display: none;
color: var(--warning-color);
@@ -45,4 +36,4 @@
& details.notes textarea {
box-sizing: border-box;
}
}
}

View File

@@ -1,457 +1,524 @@
:root {
--bookmark-title-color: var(--primary-text-color);
--bookmark-title-weight: 500;
--bookmark-description-color: var(--text-color);
--bookmark-description-weight: 400;
--bookmark-actions-color: var(--secondary-text-color);
--bookmark-actions-hover-color: var(--text-color);
--bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--gray-50);
--bookmark-title-color: var(--primary-text-color);
--bookmark-title-weight: 500;
--bookmark-description-color: var(--text-color);
--bookmark-description-weight: 400;
--bookmark-actions-color: var(--secondary-text-color);
--bookmark-actions-hover-color: var(--text-color);
--bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--gray-50);
}
/* Bookmark page grid */
.bookmarks-page.grid {
grid-gap: var(--unit-9);
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;
flex: 1 1 0;
display: flex;
max-width: 300px;
margin-left: auto;
& form {
width: 100%;
}
& form {
width: 100%;
}
@media (max-width: 600px) {
max-width: initial;
margin-left: 0;
}
@media (max-width: 600px) {
max-width: initial;
margin-left: 0;
}
/* Regular input */
/* Regular input */
& input[type='search'] {
height: var(--control-size);
-webkit-appearance: none;
}
/* Enhanced auto-complete input */
/* This needs a bit more wrangling to make the CSS component align with the attached button */
& .form-autocomplete {
height: var(--control-size);
& .form-autocomplete-input {
width: 100%;
height: var(--control-size);
& input[type='search'] {
width: 100%;
height: 100%;
margin: 0;
border: none;
}
}
}
/* Group search options button with search button */
& input[type="search"] {
height: var(--control-size);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow-xs);
-webkit-appearance: none;
}
& input, & .form-autocomplete-input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
/* Enhanced auto-complete input */
/* This needs a bit more wrangling to make the CSS component align with the attached button */
& .form-autocomplete {
height: var(--control-size);
& .form-autocomplete-input {
width: 100%;
height: var(--control-size);
& input[type="search"] {
width: 100%;
height: 100%;
margin: 0;
border: none;
}
}
}
/* Group search options button with search button */
height: var(--control-size);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow-xs);
& input,
& .form-autocomplete-input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
& .dropdown-toggle {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
outline-offset: calc(var(--focus-outline-offset) * -1);
}
/* Search option menu styles */
& .dropdown {
& .menu {
padding: var(--unit-4);
min-width: 250px;
font-size: var(--font-size-sm);
}
& .dropdown-toggle {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
outline-offset: calc(var(--focus-outline-offset) * -1);
& .menu .actions {
margin-top: var(--unit-4);
display: flex;
justify-content: space-between;
}
/* Search option menu styles */
& .dropdown {
& .menu {
padding: var(--unit-4);
min-width: 250px;
font-size: var(--font-size-sm);
}
& .menu .actions {
margin-top: var(--unit-4);
display: flex;
justify-content: space-between;
}
& .form-group:first-of-type {
margin-top: 0;
}
& .form-group {
margin-bottom: var(--unit-3);
}
& .radio-group {
& .form-label {
margin-bottom: var(--unit-1);
}
& .form-radio.form-inline {
margin: 0 var(--unit-2) 0 0;
padding: 0;
display: inline-flex;
align-items: center;
column-gap: var(--unit-1);
}
& .form-icon {
top: 0;
position: relative;
}
}
& .form-group:first-of-type {
margin-top: 0;
}
& .form-group {
margin-bottom: var(--unit-3);
}
& .radio-group {
& .form-label {
margin-bottom: var(--unit-1);
}
& .form-radio.form-inline {
margin: 0 var(--unit-2) 0 0;
padding: 0;
display: inline-flex;
align-items: center;
column-gap: var(--unit-1);
}
& .form-icon {
top: 0;
position: relative;
}
}
}
}
/* Bookmark list */
ul.bookmark-list {
list-style: none;
margin: 0;
padding: 0;
list-style: none;
margin: 0;
padding: 0;
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
}
@keyframes appear {
0% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
0% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Bookmarks */
li[ld-bookmark-item] {
position: relative;
display: flex;
gap: var(--unit-2);
margin-top: 0;
margin-bottom: var(--unit-3);
& .content {
flex: 1 1 0;
min-width: 0;
}
& .preview-image {
flex: 0 0 auto;
width: 100px;
height: 60px;
margin-top: var(--unit-h);
border-radius: var(--border-radius);
border: solid 1px var(--border-color);
object-fit: cover;
&.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--body-color-contrast);
& .img {
width: var(--unit-12);
height: var(--unit-12);
background-color: var(--tertiary-text-color);
-webkit-mask: url(preview-placeholder.svg) no-repeat center;
mask: url(preview-placeholder.svg) no-repeat center;
}
}
}
& .form-checkbox.bulk-edit-checkbox {
display: none;
}
& .title {
position: relative;
}
& .title img {
position: absolute;
width: 16px;
height: 16px;
left: 0;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
& .title img + a {
padding-left: 22px;
}
& .title a {
color: var(--bookmark-title-color);
font-weight: var(--bookmark-title-weight);
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& .title a[data-tooltip]:hover::after,
& .title a[data-tooltip]:focus::after {
content: attr(data-tooltip);
position: absolute;
z-index: 10;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 90%;
height: fit-content;
background-color: #292f62;
color: #fff;
padding: var(--unit-1);
border-radius: var(--border-radius);
border: 1px solid #424a8c;
font-size: var(--font-size-sm);
font-style: normal;
white-space: normal;
pointer-events: none;
animation: 0.3s ease 0s appear;
}
@media (pointer: coarse) {
& .title a[data-tooltip]::after {
display: none;
}
}
&.unread .title a {
font-style: italic;
}
& .url-path,
& .url-display {
font-size: var(--font-size-sm);
color: var(--secondary-link-color);
}
& .description {
color: var(--bookmark-description-color);
font-weight: var(--bookmark-description-weight);
}
& .description.separate {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
overflow: hidden;
}
& .tags {
& a,
& a:visited:hover {
color: var(--alternative-color);
}
}
& .actions,
& .extra-actions {
display: flex;
gap: var(--unit-2);
margin-top: 0;
margin-bottom: var(--unit-3);
align-items: baseline;
flex-wrap: wrap;
column-gap: var(--unit-2);
}
& .content {
flex: 1 1 0;
min-width: 0;
@media (max-width: 600px) {
& .extra-actions {
width: 100%;
margin-top: var(--unit-1);
}
}
& img.preview-image {
flex: 0 0 auto;
width: 100px;
height: 60px;
margin-top: var(--unit-h);
object-fit: cover;
border-radius: var(--border-radius);
border: solid 1px var(--border-color);
}
& .form-checkbox.bulk-edit-checkbox {
display: none;
}
& .title {
position: relative;
}
& .title img {
position: absolute;
width: 16px;
height: 16px;
left: 0;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
& .title img + a {
padding-left: 22px;
}
& .title a {
color: var(--bookmark-title-color);
font-weight: var(--bookmark-title-weight);
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& .title a[data-tooltip]:hover::after, & .title a[data-tooltip]:focus::after {
content: attr(data-tooltip);
position: absolute;
z-index: 10;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 90%;
height: fit-content;
background-color: #292f62;
color: #fff;
padding: var(--unit-1);
border-radius: var(--border-radius);
border: 1px solid #424a8c;
font-size: var(--font-size-sm);
font-style: normal;
white-space: normal;
pointer-events: none;
animation: 0.3s ease 0s appear;
}
@media (pointer: coarse) {
& .title a[data-tooltip]::after {
display: none;
}
}
&.unread .title a {
font-style: italic;
}
& .url-path, & .url-display {
font-size: var(--font-size-sm);
color: var(--secondary-link-color);
}
& .description {
color: var(--bookmark-description-color);
font-weight: var(--bookmark-description-weight);
}
& .description.separate {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
overflow: hidden;
}
& .tags {
& a, & a:visited:hover {
color: var(--alternative-color);
}
}
& .actions, & .extra-actions {
display: flex;
align-items: baseline;
flex-wrap: wrap;
column-gap: var(--unit-2);
}
@media (max-width: 600px) {
& .extra-actions {
width: 100%;
margin-top: var(--unit-1);
}
}
& .actions {
color: var(--bookmark-actions-color);
font-size: var(--font-size-sm);
& a, & button.btn-link {
color: var(--bookmark-actions-color);
--btn-icon-color: var(--bookmark-actions-color);
font-weight: var(--bookmark-actions-weight);
padding: 0;
height: auto;
vertical-align: unset;
border: none;
box-sizing: border-box;
transition: none;
text-decoration: none;
&:focus,
&:hover,
&:active,
&.active {
color: var(--bookmark-actions-hover-color);
--btn-icon-color: var(--bookmark-actions-hover-color);
}
}
& .actions {
color: var(--bookmark-actions-color);
font-size: var(--font-size-sm);
& a,
& button.btn-link {
color: var(--bookmark-actions-color);
--btn-icon-color: var(--bookmark-actions-color);
font-weight: var(--bookmark-actions-weight);
padding: 0;
height: auto;
vertical-align: unset;
border: none;
box-sizing: border-box;
transition: none;
text-decoration: none;
&:focus,
&:hover,
&:active,
&.active {
color: var(--bookmark-actions-hover-color);
--btn-icon-color: var(--bookmark-actions-hover-color);
}
}
}
}
.bookmark-pagination {
margin-top: var(--unit-4);
margin-top: var(--unit-4);
/* Remove left padding from first pagination link */
/* Remove left padding from first pagination link */
& .page-item:first-child a {
padding-left: 0;
& .page-item:first-child a {
padding-left: 0;
}
&.sticky {
position: sticky;
bottom: 0;
border-top: solid 1px var(--secondary-border-color);
background: var(--body-color);
padding-bottom: var(--unit-h);
&:before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: calc(
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
);
width: calc(
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
);
background: var(--body-color);
}
}
& .pagination {
overflow: hidden;
}
}
.tag-cloud {
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
& .selected-tags {
margin-bottom: var(--unit-4);
& .selected-tags {
margin-bottom: var(--unit-4);
& a,
& a:visited:hover {
color: var(--error-color);
}
& a,
& a:visited:hover {
color: var(--error-color);
}
}
& .unselected-tags {
& a,
& a:visited:hover {
color: var(--alternative-color);
}
& .unselected-tags {
& a,
& a:visited:hover {
color: var(--alternative-color);
}
}
& .group {
margin-bottom: var(--unit-3);
}
& .group {
margin-bottom: var(--unit-3);
}
& .highlight-char {
font-weight: bold;
text-transform: uppercase;
color: var(--alternative-color-dark);
}
& .highlight-char {
font-weight: bold;
text-transform: uppercase;
color: var(--alternative-color-dark);
}
}
/* Bookmark notes */
ul.bookmark-list {
& .notes {
display: none;
max-height: 300px;
margin: var(--unit-1) 0;
overflow-y: auto;
background: var(--body-color-contrast);
border-radius: var(--border-radius);
}
& .notes {
display: none;
max-height: 300px;
margin: var(--unit-1) 0;
overflow-y: auto;
background: var(--body-color-contrast);
border-radius: var(--border-radius);
}
& .notes .markdown {
padding: var(--unit-2) var(--unit-3);
}
& .notes .markdown {
padding: var(--unit-2) var(--unit-3);
}
&.show-notes .notes,
& li.show-notes .notes {
display: block;
}
&.show-notes .notes,
& li.show-notes .notes {
display: block;
}
}
/* Bookmark bulk edit */
:root {
--bulk-edit-toggle-width: 16px;
--bulk-edit-toggle-offset: 8px;
--bulk-edit-bar-offset: calc(var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset)));
--bulk-edit-transition-duration: 400ms;
--bulk-edit-toggle-width: 16px;
--bulk-edit-toggle-offset: 8px;
--bulk-edit-bar-offset: calc(
var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset))
);
--bulk-edit-transition-duration: 400ms;
}
[ld-bulk-edit] {
& .bulk-edit-bar {
margin-top: -1px;
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
margin-bottom: var(--unit-4);
max-height: 0;
overflow: hidden;
transition: max-height var(--bulk-edit-transition-duration);
background: var(--bulk-actions-bg-color);
& .bulk-edit-bar {
margin-top: -1px;
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
margin-bottom: var(--unit-4);
max-height: 0;
overflow: hidden;
transition: max-height var(--bulk-edit-transition-duration);
background: var(--bulk-actions-bg-color);
}
&.active .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px var(--secondary-border-color);
}
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
&.active 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;
}
/* make sticky pagination expand to cover checkboxes to the left */
&.active .bookmark-pagination.sticky:before {
content: "";
position: absolute;
top: -1px;
bottom: 0;
left: calc(
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
);
width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset));
background: var(--body-color);
border-top: solid 1px var(--secondary-border-color);
}
/* All checkbox */
& .form-checkbox.bulk-edit-checkbox.all {
display: block;
width: var(--bulk-edit-toggle-width);
margin: 0 0 0 var(--bulk-edit-toggle-offset);
padding: 0;
}
/* Bookmark checkboxes */
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
display: block;
position: absolute;
width: var(--bulk-edit-toggle-width);
min-height: var(--bulk-edit-toggle-width);
left: calc(
-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset)
);
top: 50%;
transform: translateY(-50%);
padding: 0;
margin: 0;
visibility: hidden;
opacity: 0;
transition: all var(--bulk-edit-transition-duration);
.form-icon {
top: 0;
}
}
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
visibility: visible;
opacity: 1;
}
/* Actions */
& .bulk-edit-actions {
display: flex;
align-items: center;
padding: var(--unit-1) 0;
border-top: solid 1px var(--secondary-border-color);
gap: var(--unit-2);
& button {
--control-padding-x-sm: 0;
}
&.active .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px var(--secondary-border-color);
& button:hover {
text-decoration: underline;
}
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
&.active section:first-of-type .content-area-header {
border-bottom-color: transparent;
& > input,
& .form-autocomplete,
& select {
width: auto;
max-width: 140px;
-webkit-appearance: none;
}
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
&.active:not(.activating) .bulk-edit-bar {
overflow: visible;
}
/* All checkbox */
& .form-checkbox.bulk-edit-checkbox.all {
display: block;
width: var(--bulk-edit-toggle-width);
margin: 0 0 0 var(--bulk-edit-toggle-offset);
padding: 0;
}
/* Bookmark checkboxes */
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
display: block;
position: absolute;
width: var(--bulk-edit-toggle-width);
min-height: var(--bulk-edit-toggle-width);
left: calc(-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset));
top: 50%;
transform: translateY(-50%);
padding: 0;
margin: 0;
visibility: hidden;
opacity: 0;
transition: all var(--bulk-edit-transition-duration);
.form-icon {
top: 0;
}
}
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
visibility: visible;
opacity: 1;
}
/* Actions */
& .bulk-edit-actions {
display: flex;
align-items: center;
padding: var(--unit-1) 0;
border-top: solid 1px var(--secondary-border-color);
gap: var(--unit-2);
& button {
--control-padding-x-sm: 0;
}
& button:hover {
text-decoration: underline;
}
& > input,
& .form-autocomplete,
& select {
width: auto;
max-width: 140px;
-webkit-appearance: none;
}
& .select-across {
margin: 0 0 0 auto;
font-size: var(--font-size-sm);
}
& .select-across {
margin: 0 0 0 auto;
font-size: var(--font-size-sm);
}
}
}

View File

@@ -2,64 +2,64 @@
/* 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 {
font-size: var(--font-size-lg);
flex: 0 0 auto;
line-height: var(--unit-9);
margin: 0;
}
.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;
}
.header-controls {
flex: 1 1 0;
display: flex;
}
}
}
@media (max-width: 600px) {
section.content-area .content-area-header {
flex-direction: column;
}
section.content-area .content-area-header {
flex-direction: column;
}
}
/* Confirm button component */
span.confirmation {
display: flex;
align-items: baseline;
gap: var(--unit-1);
display: flex;
align-items: baseline;
gap: var(--unit-1);
color: var(--error-color) !important;
svg {
align-self: center;
}
.btn.btn-link {
color: var(--error-color) !important;
svg {
align-self: center;
}
.btn.btn-link {
color: var(--error-color) !important;
&:hover {
text-decoration: underline;
}
&:hover {
text-decoration: underline;
}
}
}
/* Divider */
.divider {
border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-5) 0;
border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-5) 0;
}
/* Turbo progress bar */
.turbo-progress-bar {
background-color: var(--primary-color);
background-color: var(--primary-color);
}

View File

@@ -1,39 +1,39 @@
/* Main layout */
body {
margin: 20px 10px;
margin: 20px 10px;
@media (min-width: 600px) {
/* Horizontal offset accounts for checkboxes that show up in bulk edit mode */
margin: 20px 32px;
}
@media (min-width: 600px) {
/* Horizontal offset accounts for checkboxes that show up in bulk edit mode */
margin: 20px 32px;
}
}
header {
margin-bottom: var(--unit-9);
margin-bottom: var(--unit-9);
.logo {
width: 28px;
height: 28px;
}
.logo {
width: 28px;
height: 28px;
}
a:hover {
text-decoration: none;
}
a:hover {
text-decoration: none;
}
h1 {
margin: 0 0 0 var(--unit-3);
font-size: var(--font-size-lg);
}
h1 {
margin: 0 0 0 var(--unit-3);
font-size: var(--font-size-lg);
}
}
header .toasts {
margin-bottom: 20px;
margin-bottom: 20px;
.toast {
margin-bottom: 0.4rem;
}
.toast {
margin-bottom: 0.4rem;
}
.toast a.btn-clear:visited {
color: currentColor;
}
.toast a.btn-clear:visited {
color: currentColor;
}
}

View File

@@ -1,40 +1,46 @@
.markdown {
& p, & ul, & ol, & pre, & blockquote {
margin: 0 0 var(--unit-2) 0;
}
& p,
& ul,
& ol,
& pre,
& blockquote {
margin: 0 0 var(--unit-2) 0;
}
& > *:first-child {
margin-top: 0;
}
& > *:first-child {
margin-top: 0;
}
& > *:last-child {
margin-bottom: 0;
}
& > *:last-child {
margin-bottom: 0;
}
& ul, & ol {
margin-left: var(--unit-4);
}
& ul,
& ol {
margin-left: var(--unit-4);
}
& ul li, & ol li {
margin-top: var(--unit-1);
}
& ul li,
& ol li {
margin-top: var(--unit-1);
}
& pre {
padding: var(--unit-1) var(--unit-2);
background-color: var(--code-bg-color);
border-radius: var(--unit-1);
overflow-x: auto;
}
& pre {
padding: var(--unit-1) var(--unit-2);
background-color: var(--code-bg-color);
border-radius: var(--unit-1);
overflow-x: auto;
}
& pre code {
background: none;
box-shadow: none;
padding: 0;
}
& pre code {
background: none;
box-shadow: none;
padding: 0;
}
& > pre:first-child:last-child {
padding: 0;
background: none;
border-radius: 0;
}
& > pre:first-child:last-child {
padding: 0;
background: none;
border-radius: 0;
}
}

View File

@@ -24,4 +24,3 @@ html.reader-mode {
height: auto;
}
}

View File

@@ -12,7 +12,7 @@
box-sizing: border-box;
}
.input-group > input[type=submit] {
.input-group > input[type="submit"] {
height: auto;
}

View File

@@ -1,143 +1,150 @@
@import "theme-light.css";
:root {
/* Color palette */
--contrast-5: hsla(241, 65%, 85%, 0.06);
--contrast-10: hsla(241, 60%, 80%, 0.14);
--contrast-20: hsla(241, 64%, 82%, 0.23);
--contrast-30: hsla(241, 69%, 84%, 0.32);
--contrast-40: hsla(241, 73%, 86%, 0.41);
--contrast-50: hsla(241, 78%, 88%, 0.5);
--contrast-60: hsla(241, 82%, 90%, 0.58);
--contrast-70: hsla(241, 87%, 92%, 0.69);
--contrast-80: hsla(241, 91%, 94%, 0.8);
--contrast-90: hsla(241, 96%, 96%, 0.9);
/* Color palette */
--contrast-5: hsla(241, 65%, 85%, 0.06);
--contrast-10: hsla(241, 60%, 80%, 0.14);
--contrast-20: hsla(241, 64%, 82%, 0.23);
--contrast-30: hsla(241, 69%, 84%, 0.32);
--contrast-40: hsla(241, 73%, 86%, 0.41);
--contrast-50: hsla(241, 78%, 88%, 0.5);
--contrast-60: hsla(241, 82%, 90%, 0.58);
--contrast-70: hsla(241, 87%, 92%, 0.69);
--contrast-80: hsla(241, 91%, 94%, 0.8);
--contrast-90: hsla(241, 96%, 96%, 0.9);
--primary-color: hsl(241, 75%, 64%);
--primary-color-highlight: hsl(241, 75%, 68%);
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
--primary-color: hsl(241, 75%, 64%);
--primary-color-highlight: hsl(241, 75%, 68%);
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
--alternative-color: hsl(179, 50%, 58%);
--alternative-color-dark: hsl(179, 80%, 75%);
--alternative-color: hsl(179, 50%, 58%);
--alternative-color-dark: hsl(179, 80%, 75%);
--success-color: hsl(142, 76%, 36%);
--success-color-highlight: hsl(142, 76%, 40%);
--success-color-shade: hsla(142, 76%, 36%, 0.1);
--success-color: hsl(142, 76%, 36%);
--success-color-highlight: hsl(142, 76%, 40%);
--success-color-shade: hsla(142, 76%, 36%, 0.1);
--warning-color: hsl(38, 92%, 50%);
--warning-color-highlight: hsl(38, 92%, 55%);
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
--warning-color: hsl(38, 92%, 50%);
--warning-color-highlight: hsl(38, 92%, 55%);
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
--error-color: hsl(0, 80%, 60%);
--error-color-highlight: hsl(0, 72%, 60%);
--error-color-shade: hsla(0, 72%, 51%, 0.1);
--error-color: hsl(0, 80%, 60%);
--error-color-highlight: hsl(0, 72%, 60%);
--error-color-shade: hsla(0, 72%, 51%, 0.1);
/* Core colors */
--text-color: var(--gray-300);
--secondary-text-color: var(--gray-400);
--tertiary-text-color: var(--gray-500);
--contrast-text-color: #fff;
--primary-text-color: hsl(241, 82%, 82%);
/* Core colors */
--text-color: var(--gray-300);
--secondary-text-color: var(--gray-400);
--tertiary-text-color: var(--gray-500);
--contrast-text-color: #fff;
--primary-text-color: hsl(241, 82%, 82%);
--link-color: var(--primary-text-color);
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
--link-color: var(--primary-text-color);
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
--icon-color: var(--text-color);
--icon-color: var(--text-color);
--border-color: var(--contrast-30);
--secondary-border-color: var(--contrast-20);
--border-color: var(--contrast-30);
--secondary-border-color: var(--contrast-20);
--body-color: hsl(241, 15%, 14%);
--body-color-contrast: var(--contrast-10);
--body-color: hsl(241, 15%, 14%);
--body-color-contrast: var(--contrast-10);
/* Focus */
--focus-outline: 2px solid hsl(241, 100%, 78%);
--focus-outline-offset: 2px;
/* Focus */
--focus-outline: 2px solid hsl(241, 100%, 78%);
--focus-outline-offset: 2px;
/* Shadows */
--box-shadow-xs: none;
--box-shadow: none;
--box-shadow-lg: none;
/* Shadows */
--box-shadow-xs: none;
--box-shadow: none;
--box-shadow-lg: none;
}
:root {
--input-bg-color: var(--contrast-5);
--input-disabled-bg-color: var(--contrast-30);
--input-text-color: var(--text-color);
--input-hint-color: var(--secondary-text-color);
--input-border-color: var(--border-color);
--input-placeholder-color: var(--tertiary-text-color);
--input-box-shadow: var(--box-shadow-xs);
--input-bg-color: var(--contrast-5);
--input-disabled-bg-color: var(--contrast-30);
--input-text-color: var(--text-color);
--input-hint-color: var(--secondary-text-color);
--input-border-color: var(--border-color);
--input-placeholder-color: var(--tertiary-text-color);
--input-box-shadow: var(--box-shadow-xs);
--checkbox-bg-color: var(--contrast-10);
--checkbox-checked-bg-color: var(--primary-color);
--checkbox-disabled-bg-color: var(--contrast-30);
--checkbox-border-color: var(--border-color);
--checkbox-icon-color: #fff;
--checkbox-bg-color: var(--contrast-10);
--checkbox-checked-bg-color: var(--primary-color);
--checkbox-disabled-bg-color: var(--contrast-30);
--checkbox-border-color: var(--border-color);
--checkbox-icon-color: #fff;
--switch-bg-color: var(--contrast-10);
--switch-border-color: var(--border-color);
--switch-toggle-color: var(--text-color);
--switch-bg-color: var(--contrast-10);
--switch-border-color: var(--border-color);
--switch-toggle-color: var(--text-color);
}
:root {
--btn-bg-color: var(--contrast-5);
--btn-hover-bg-color: var(--contrast-20);
--btn-border-color: var(--border-color);
--btn-text-color: var(--text-color);
--btn-icon-color: var(--icon-color);
--btn-font-weight: 400;
--btn-box-shadow: var(--box-shadow-xs);
--btn-bg-color: var(--contrast-5);
--btn-hover-bg-color: var(--contrast-20);
--btn-border-color: var(--border-color);
--btn-text-color: var(--text-color);
--btn-icon-color: var(--icon-color);
--btn-font-weight: 400;
--btn-box-shadow: var(--box-shadow-xs);
--btn-primary-bg-color: var(--primary-color);
--btn-primary-hover-bg-color: var(--primary-color-highlight);
--btn-primary-text-color: var(--contrast-text-color);
--btn-primary-bg-color: var(--primary-color);
--btn-primary-hover-bg-color: var(--primary-color-highlight);
--btn-primary-text-color: var(--contrast-text-color);
--btn-success-bg-color: var(--success-color);
--btn-success-hover-bg-color: var(--success-color-highlight);
--btn-success-text-color: var(--contrast-text-color);
--btn-success-bg-color: var(--success-color);
--btn-success-hover-bg-color: var(--success-color-highlight);
--btn-success-text-color: var(--contrast-text-color);
--btn-error-bg-color: var(--error-color);
--btn-error-hover-bg-color: var(--error-color-highlight);
--btn-error-text-color: var(--contrast-text-color);
--btn-error-bg-color: var(--error-color);
--btn-error-hover-bg-color: var(--error-color-highlight);
--btn-error-text-color: var(--contrast-text-color);
--btn-link-text-color: var(--link-color);
--btn-link-hover-text-color: var(--link-color);
--btn-link-text-color: var(--link-color);
--btn-link-hover-text-color: var(--link-color);
}
:root {
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
--modal-container-bg-color: hsl(241, 20%, 20%);
--modal-container-border-color: var(--contrast-30);
--modal-border-radius: var(--border-radius-lg);
--modal-box-shadow: none;
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
--modal-container-bg-color: hsl(241, 20%, 20%);
--modal-container-border-color: var(--contrast-30);
--modal-border-radius: var(--border-radius-lg);
--modal-box-shadow: none;
}
:root {
--menu-bg-color: hsl(241, 20%, 20%);
--menu-border-color: var(--contrast-30);
--menu-border-radius: var(--border-radius);
--menu-box-shadow: none;
--menu-item-color: var(--text-color);
--menu-item-hover-color: var(--text-color);
--menu-item-bg-color: transparent;
--menu-item-hover-bg-color: var(--contrast-20);
--menu-bg-color: hsl(241, 20%, 20%);
--menu-border-color: var(--contrast-30);
--menu-border-radius: var(--border-radius);
--menu-box-shadow: none;
--menu-item-color: var(--text-color);
--menu-item-hover-color: var(--text-color);
--menu-item-bg-color: transparent;
--menu-item-hover-bg-color: var(--contrast-20);
}
:root {
--tab-color: var(--text-color);
--tab-hover-color: var(--primary-text-color);
--tab-active-color: var(--primary-text-color);
--tab-highlight-color: var(--primary-text-color);
--tab-color: var(--text-color);
--tab-hover-color: var(--primary-text-color);
--tab-active-color: var(--primary-text-color);
--tab-highlight-color: var(--primary-text-color);
}
:root {
--bookmark-title-color: var(--primary-text-color);
--bookmark-title-weight: 500;
--bookmark-description-color: var(--text-color);
--bookmark-description-weight: 400;
--bookmark-actions-color: var(--secondary-text-color);
--bookmark-actions-hover-color: var(--text-color);
--bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--contrast-5);
}
--bookmark-title-color: var(--primary-text-color);
--bookmark-title-weight: 500;
--bookmark-description-color: var(--text-color);
--bookmark-description-weight: 400;
--bookmark-actions-color: var(--secondary-text-color);
--bookmark-actions-hover-color: var(--text-color);
--bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--contrast-5);
}
/* Try to force dark color scheme for all native elements (e.g. upload button
in file inputs, native select dropdown). For the select dropdown some browsers
ignore this and use whatever users have configured in their system settings. */
:root {
color-scheme: dark;
}

View File

@@ -61,7 +61,8 @@ h1 {
figcaption,
figure,
main { /* 1 */
main {
/* 1 */
display: block;
}
@@ -256,7 +257,8 @@ textarea {
*/
button,
input { /* 1 */
input {
/* 1 */
overflow: visible;
}
@@ -266,7 +268,8 @@ input { /* 1 */
*/
button,
select { /* 1 */
select {
/* 1 */
text-transform: none;
}
@@ -299,7 +302,6 @@ button::-moz-focus-inner,
* Restore the focus styles unset by the previous rule (removed).
*/
/**
* Change the border, margin, and padding in all browsers (opinionated) (changed).
*/

View File

@@ -1,38 +1,38 @@
/* Animations */
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes slide-down {
0% {
opacity: 0;
transform: translateY(calc(-1 * var(--unit-8)));
}
100% {
opacity: 1;
transform: translateY(0);
}
0% {
opacity: 0;
transform: translateY(calc(-1 * var(--unit-8)));
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -3,41 +3,41 @@ html:lang(zh),
html:lang(zh-Hans),
.lang-zh,
.lang-zh-hans {
font-family: var(--cjk-zh-hans-font-family);
font-family: var(--cjk-zh-hans-font-family);
}
html:lang(zh-Hant),
.lang-zh-hant {
font-family: var(--cjk-zh-hant-font-family);
font-family: var(--cjk-zh-hant-font-family);
}
html:lang(ja),
.lang-ja {
font-family: var(--cjk-jp-font-family);
font-family: var(--cjk-jp-font-family);
}
html:lang(ko),
.lang-ko {
font-family: var(--cjk-ko-font-family);
font-family: var(--cjk-ko-font-family);
}
:lang(zh),
:lang(ja),
.lang-cjk {
& ins,
& u {
border-bottom: var(--border-width) solid;
text-decoration: none;
}
& ins,
& u {
border-bottom: var(--border-width) solid;
text-decoration: none;
}
& del + del,
& del + s,
& ins + ins,
& ins + u,
& s + del,
& s + s,
& u + ins,
& u + u {
margin-left: .125em;
}
& del + del,
& del + s,
& ins + ins,
& ins + u,
& s + del,
& s + s,
& u + ins,
& u + u {
margin-left: 0.125em;
}
}

View File

@@ -1,55 +1,57 @@
/* Autocomplete */
.form-autocomplete {
position: relative;
position: relative;
& .form-autocomplete-input {
align-content: flex-start;
display: flex;
flex-wrap: wrap;
height: auto;
min-height: var(--unit-8);
padding: var(--unit-h);
background: var(--input-bg-color);
& .form-autocomplete-input {
align-content: flex-start;
display: flex;
flex-wrap: wrap;
height: auto;
min-height: var(--unit-8);
padding: var(--unit-h);
background: var(--input-bg-color);
&.is-focused {
outline: var(--focus-outline);
outline-offset: calc(var(--focus-outline-offset) * -1);
}
& .form-input {
background: transparent;
border-color: transparent;
box-shadow: none;
display: inline-block;
flex: 1 0 auto;
height: var(--unit-6);
line-height: var(--unit-4);
margin: var(--unit-h);
width: auto;
&:focus {
outline: none;
}
}
&.is-focused {
outline: var(--focus-outline);
outline-offset: calc(var(--focus-outline-offset) * -1);
}
& .menu {
left: 0;
position: absolute;
top: 100%;
width: 100%;
& .form-input {
background: transparent;
border-color: transparent;
box-shadow: none;
display: inline-block;
flex: 1 0 auto;
height: var(--unit-6);
line-height: var(--unit-4);
margin: var(--unit-h);
width: auto;
& .menu-item.selected > a, & .menu-item > a:hover {
background: var(--menu-item-hover-bg-color);
color: var(--menu-item-hover-color);
}
& .group-item, & .group-item:hover {
color: var(--tertiary-text-color);
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
&:focus {
outline: none;
}
}
}
}
& .menu {
left: 0;
position: absolute;
top: 100%;
width: 100%;
& .menu-item.selected > a,
& .menu-item > a:hover {
background: var(--menu-item-hover-bg-color);
color: var(--menu-item-hover-color);
}
& .group-item,
& .group-item:hover {
color: var(--tertiary-text-color);
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}
}

View File

@@ -1,64 +1,64 @@
/* Badges */
.badge {
position: relative;
white-space: nowrap;
position: relative;
white-space: nowrap;
&[data-badge],
&:not([data-badge]) {
&::after {
background: var(--primary-color);
background-clip: padding-box;
border-radius: .5rem;
box-shadow: 0 0 0 1px var(--body-color);
color: var(--contrast-text-color);
content: attr(data-badge);
display: inline-block;
transform: translate(-.05rem, -.5rem);
}
&[data-badge],
&:not([data-badge]) {
&::after {
background: var(--primary-color);
background-clip: padding-box;
border-radius: 0.5rem;
box-shadow: 0 0 0 1px var(--body-color);
color: var(--contrast-text-color);
content: attr(data-badge);
display: inline-block;
transform: translate(-0.05rem, -0.5rem);
}
}
&[data-badge] {
&::after {
font-size: var(--font-size-sm);
height: .9rem;
line-height: 1;
min-width: .9rem;
padding: .1rem .2rem;
text-align: center;
white-space: nowrap;
}
&[data-badge] {
&::after {
font-size: var(--font-size-sm);
height: 0.9rem;
line-height: 1;
min-width: 0.9rem;
padding: 0.1rem 0.2rem;
text-align: center;
white-space: nowrap;
}
}
&:not([data-badge]),
&[data-badge=""] {
&::after {
height: 6px;
min-width: 6px;
padding: 0;
width: 6px;
}
&:not([data-badge]),
&[data-badge=""] {
&::after {
height: 6px;
min-width: 6px;
padding: 0;
width: 6px;
}
}
/* Badges for Buttons */
/* Badges for Buttons */
&.btn {
&::after {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%);
}
&.btn {
&::after {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%);
}
}
/* Badges for Avatars */
/* Badges for Avatars */
&.avatar {
&::after {
position: absolute;
top: 14.64%;
right: 14.64%;
transform: translate(50%, -50%);
z-index: var(--zindex-1);
}
&.avatar {
&::after {
position: absolute;
top: 14.64%;
right: 14.64%;
transform: translate(50%, -50%);
z-index: var(--zindex-1);
}
}
}

View File

@@ -2,60 +2,60 @@
*,
*::before,
*::after {
box-sizing: inherit;
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: var(--html-font-size);
line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
box-sizing: border-box;
font-size: var(--html-font-size);
line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
}
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
html {
scrollbar-gutter: stable;
scrollbar-gutter: stable;
}
@media (pointer: coarse) {
html {
scrollbar-gutter: initial;
}
html {
scrollbar-gutter: initial;
}
}
body {
background: var(--body-color);
color: var(--text-color);
font-family: var(--body-font-family);
font-size: var(--font-size);
overflow-x: hidden;
text-rendering: optimizeLegibility;
background: var(--body-color);
color: var(--text-color);
font-family: var(--body-font-family);
font-size: var(--font-size);
overflow-x: hidden;
text-rendering: optimizeLegibility;
}
a {
color: var(--link-color);
outline: none;
text-decoration: none;
color: var(--link-color);
outline: none;
text-decoration: none;
}
a:focus-visible {
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}
a:focus,
a:hover,
a:active,
a.active {
text-decoration: underline;
text-decoration: underline;
}
summary {
cursor: pointer;
cursor: pointer;
}
summary:focus-visible {
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}

View File

@@ -1,264 +1,268 @@
/* Buttons */
:root {
--btn-bg-color: var(--body-color);
--btn-hover-bg-color: var(--gray-50);
--btn-border-color: var(--border-color);
--btn-text-color: var(--text-color);
--btn-icon-color: var(--icon-color);
--btn-font-weight: 400;
--btn-box-shadow: var(--box-shadow-xs);
--btn-bg-color: var(--body-color);
--btn-hover-bg-color: var(--gray-50);
--btn-border-color: var(--border-color);
--btn-text-color: var(--text-color);
--btn-icon-color: var(--icon-color);
--btn-font-weight: 400;
--btn-box-shadow: var(--box-shadow-xs);
--btn-primary-bg-color: var(--primary-color);
--btn-primary-hover-bg-color: var(--primary-color-highlight);
--btn-primary-text-color: var(--contrast-text-color);
--btn-primary-bg-color: var(--primary-color);
--btn-primary-hover-bg-color: var(--primary-color-highlight);
--btn-primary-text-color: var(--contrast-text-color);
--btn-success-bg-color: var(--success-color);
--btn-success-hover-bg-color: var(--success-color-highlight);
--btn-success-text-color: var(--contrast-text-color);
--btn-success-bg-color: var(--success-color);
--btn-success-hover-bg-color: var(--success-color-highlight);
--btn-success-text-color: var(--contrast-text-color);
--btn-error-bg-color: var(--error-color);
--btn-error-hover-bg-color: var(--error-color-highlight);
--btn-error-text-color: var(--contrast-text-color);
--btn-error-bg-color: var(--error-color);
--btn-error-hover-bg-color: var(--error-color-highlight);
--btn-error-text-color: var(--contrast-text-color);
--btn-link-text-color: var(--link-color);
--btn-link-hover-text-color: var(--link-color);
--btn-link-text-color: var(--link-color);
--btn-link-hover-text-color: var(--link-color);
}
.btn {
appearance: none;
background: var(--btn-bg-color);
border: var(--border-width) solid var(--btn-border-color);
border-radius: var(--border-radius);
color: var(--btn-text-color);
font-weight: var(--btn-font-weight);
cursor: pointer;
display: inline-flex;
align-items: baseline;
justify-content: center;
font-size: var(--font-size);
height: var(--control-size);
line-height: var(--line-height);
outline: none;
padding: var(--control-padding-y) var(--control-padding-x);
box-shadow: var(--btn-box-shadow);
text-align: center;
text-decoration: none;
transition: background 0.2s, border 0.2s, box-shadow 0.2s, color 0.2s;
user-select: none;
vertical-align: middle;
white-space: nowrap;
appearance: none;
background: var(--btn-bg-color);
border: var(--border-width) solid var(--btn-border-color);
border-radius: var(--border-radius);
color: var(--btn-text-color);
font-weight: var(--btn-font-weight);
cursor: pointer;
display: inline-flex;
align-items: baseline;
justify-content: center;
font-size: var(--font-size);
height: var(--control-size);
line-height: var(--line-height);
outline: none;
padding: var(--control-padding-y) var(--control-padding-x);
box-shadow: var(--btn-box-shadow);
text-align: center;
text-decoration: none;
transition:
background 0.2s,
border 0.2s,
box-shadow 0.2s,
color 0.2s;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:focus-visible {
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}
&:focus-visible {
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}
&:hover {
background: var(--btn-hover-bg-color);
text-decoration: none;
}
&[disabled],
&:disabled,
&.disabled {
cursor: default;
opacity: 0.5;
pointer-events: none;
}
&:focus,
&:hover,
&:active,
&.active {
text-decoration: none;
}
/* Button Primary */
&.btn-primary {
background: var(--btn-primary-bg-color);
border-color: transparent;
color: var(--btn-primary-text-color);
--btn-icon-color: var(--btn-primary-text-color);
&:hover {
background: var(--btn-hover-bg-color);
text-decoration: none;
background: var(--btn-primary-hover-bg-color);
}
&[disabled],
&:disabled,
&.disabled {
cursor: default;
opacity: 0.5;
pointer-events: none;
&.loading {
&::after {
border-bottom-color: var(--btn-primary-text-color);
border-left-color: var(--btn-primary-text-color);
}
}
}
/* Button Colors */
&.btn-success {
background: var(--btn-success-bg-color);
border-color: transparent;
color: var(--btn-success-text-color);
--btn-icon-color: var(--btn-success-text-color);
&:hover {
background: var(--btn-success-hover-bg-color);
}
}
&.btn-error {
--btn-border-color: var(--error-color);
--btn-text-color: var(--error-color);
&:hover {
--btn-hover-bg-color: var(--error-color-shade);
}
}
/* Button Link */
&.btn-link {
background: transparent;
border-color: transparent;
box-shadow: none;
color: var(--btn-link-text-color);
--btn-icon-color: var(--btn-link-text-color);
&:hover {
color: var(--btn-link-hover-text-color);
--btn-icon-color: var(--btn-link-hover-text-color);
}
&:focus,
&:hover,
&:active,
&.active {
text-decoration: none;
text-decoration: none;
}
}
/* Button Primary */
/* Button Sizes */
&.btn-primary {
background: var(--btn-primary-bg-color);
border-color: transparent;
color: var(--btn-primary-text-color);
--btn-icon-color: var(--btn-primary-text-color);
&.btn-sm {
font-size: var(--font-size-sm);
height: var(--control-size-sm);
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
}
&:hover {
background: var(--btn-primary-hover-bg-color);
}
&.btn-lg {
font-size: var(--font-size-lg);
height: var(--control-size-lg);
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
}
&.loading {
&::after {
border-bottom-color: var(--btn-primary-text-color);
border-left-color: var(--btn-primary-text-color);
}
}
}
/* Button Block */
/* Button Colors */
&.btn-block {
display: block;
width: 100%;
}
&.btn-success {
background: var(--btn-success-bg-color);
border-color: transparent;
color: var(--btn-success-text-color);
--btn-icon-color: var(--btn-success-text-color);
/* Button Action */
&:hover {
background: var(--btn-success-hover-bg-color);
}
}
&.btn-error {
--btn-border-color: var(--error-color);
--btn-text-color: var(--error-color);
&:hover {
--btn-hover-bg-color: var(--error-color-shade);
}
}
/* Button Link */
&.btn-link {
background: transparent;
border-color: transparent;
box-shadow: none;
color: var(--btn-link-text-color);
--btn-icon-color: var(--btn-link-text-color);
&:hover {
color: var(--btn-link-hover-text-color);
--btn-icon-color: var(--btn-link-hover-text-color);
}
&:focus,
&:hover,
&:active,
&.active {
text-decoration: none;
}
}
/* Button Sizes */
&.btn-action {
width: var(--control-size);
padding-left: 0;
padding-right: 0;
&.btn-sm {
font-size: var(--font-size-sm);
height: var(--control-size-sm);
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
width: var(--control-size-sm);
}
&.btn-lg {
font-size: var(--font-size-lg);
height: var(--control-size-lg);
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
width: var(--control-size-lg);
}
}
/* Button Block */
/* Button Clear */
&.btn-block {
display: block;
width: 100%;
&.btn-clear {
background: transparent;
border: 0;
color: currentColor;
box-shadow: none;
height: var(--unit-5);
line-height: var(--unit-4);
margin-left: var(--unit-1);
margin-right: -2px;
opacity: 1;
padding: var(--unit-h);
text-decoration: none;
width: var(--unit-5);
&::before {
content: "\2715";
}
}
/* Button Action */
/* Wider button */
&.btn-action {
width: var(--control-size);
padding-left: 0;
padding-right: 0;
&.btn-wide {
padding-left: var(--unit-6);
padding-right: var(--unit-6);
}
&.btn-sm {
width: var(--control-size-sm);
}
/* Small icon button */
&.btn-lg {
width: var(--control-size-lg);
}
&.btn-sm.btn-icon {
display: inline-flex;
align-items: baseline;
gap: var(--unit-h);
svg {
align-self: center;
}
}
/* Button Clear */
/* Button icons */
&.btn-clear {
background: transparent;
border: 0;
color: currentColor;
box-shadow: none;
height: var(--unit-5);
line-height: var(--unit-4);
margin-left: var(--unit-1);
margin-right: -2px;
opacity: 1;
padding: var(--unit-h);
text-decoration: none;
width: var(--unit-5);
&::before {
content: "\2715";
}
}
/* Wider button */
&.btn-wide {
padding-left: var(--unit-6);
padding-right: var(--unit-6);
}
/* Small icon button */
&.btn-sm.btn-icon {
display: inline-flex;
align-items: baseline;
gap: var(--unit-h);
svg {
align-self: center;
}
}
/* Button icons */
& svg {
color: var(--btn-icon-color);
align-self: center;
}
& svg {
color: var(--btn-icon-color);
align-self: center;
}
}
/* Button groups */
.btn-group {
display: inline-flex;
flex-wrap: wrap;
display: inline-flex;
flex-wrap: wrap;
.btn {
flex: 1 0 auto;
&:first-child:not(:last-child) {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
&:not(:first-child):not(:last-child) {
border-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
&:last-child:not(:first-child) {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
&:focus,
&:hover,
&:active,
&.active {
z-index: var(--zindex-0);
}
}
&.btn-group-block {
display: flex;
.btn {
flex: 1 0 auto;
&:first-child:not(:last-child) {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
&:not(:first-child):not(:last-child) {
border-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
&:last-child:not(:first-child) {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
&:focus,
&:hover,
&:active,
&.active {
z-index: var(--zindex-0);
}
flex: 1 0 0;
}
&.btn-group-block {
display: flex;
.btn {
flex: 1 0 0;
}
}
}
}
}

View File

@@ -1,30 +1,30 @@
/* Code */
:root {
--code-bg-color: var(--body-color-contrast);
--code-color: var(--text-color);
--code-bg-color: var(--body-color-contrast);
--code-color: var(--text-color);
}
code {
border-radius: var(--border-radius);
line-height: 1.25;
padding: .1rem .2rem;
background: var(--code-bg-color);
color: var(--code-color);
font-size: 85%;
border-radius: var(--border-radius);
line-height: 1.25;
padding: 0.1rem 0.2rem;
background: var(--code-bg-color);
color: var(--code-color);
font-size: 85%;
}
.code {
border-radius: var(--border-radius);
background: var(--code-bg-color);
color: var(--text-color);
position: relative;
border-radius: var(--border-radius);
background: var(--code-bg-color);
color: var(--text-color);
position: relative;
& code {
color: inherit;
display: block;
line-height: 1.5;
overflow-x: auto;
padding: var(--unit-2);
width: 100%;
}
& code {
color: inherit;
display: block;
line-height: 1.5;
overflow-x: auto;
padding: var(--unit-2);
width: 100%;
}
}

View File

@@ -1,10 +1,12 @@
/* Dropdown */
.dropdown {
--dropdown-focus-display: block;
display: inline-block;
position: relative;
.menu {
animation: fade-in .15s ease 1;
animation: fade-in 0.15s ease 1;
display: none;
left: 0;
max-height: 50vh;
@@ -20,9 +22,13 @@
}
}
&.active .menu,
.dropdown-toggle:focus + .menu,
.menu:hover {
&:focus-within .menu {
/* Use custom CSS property to allow disabling opening on focus when using JS */
display: var(--dropdown-focus-display);
}
&.active .menu {
/* Always show menu when class is added through JS */
display: block;
}

View File

@@ -1,21 +1,21 @@
/* Empty states (or Blank slates) */
.empty {
background: var(--body-color-contrast);
border-radius: var(--border-radius);
color: var(--secondary-text-color);
text-align: center;
padding: var(--unit-16) var(--unit-8);
background: var(--body-color-contrast);
border-radius: var(--border-radius);
color: var(--secondary-text-color);
text-align: center;
padding: var(--unit-16) var(--unit-8);
.empty-icon {
margin-bottom: var(--layout-spacing-lg);
}
.empty-icon {
margin-bottom: var(--layout-spacing-lg);
}
.empty-title,
.empty-subtitle {
margin: var(--layout-spacing) auto;
}
.empty-title,
.empty-subtitle {
margin: var(--layout-spacing) auto;
}
.empty-action {
margin-top: var(--layout-spacing-lg);
}
.empty-action {
margin-top: var(--layout-spacing-lg);
}
}

View File

@@ -1,515 +1,547 @@
/* Forms */
:root {
--input-bg-color: var(--body-color);
--input-disabled-bg-color: var(--gray-100);
--input-text-color: var(--text-color);
--input-hint-color: var(--secondary-text-color);
--input-border-color: var(--border-color);
--input-placeholder-color: var(--tertiary-text-color);
--input-box-shadow: var(--box-shadow-xs);
--input-bg-color: var(--body-color);
--input-disabled-bg-color: var(--gray-100);
--input-text-color: var(--text-color);
--input-hint-color: var(--secondary-text-color);
--input-border-color: var(--border-color);
--input-placeholder-color: var(--tertiary-text-color);
--input-box-shadow: var(--box-shadow-xs);
--checkbox-bg-color: var(--body-color);
--checkbox-checked-bg-color: var(--primary-color);
--checkbox-disabled-bg-color: var(--gray-100);
--checkbox-border-color: var(--border-color);
--checkbox-icon-color: #fff;
--checkbox-bg-color: var(--body-color);
--checkbox-checked-bg-color: var(--primary-color);
--checkbox-disabled-bg-color: var(--gray-100);
--checkbox-border-color: var(--border-color);
--checkbox-icon-color: #fff;
--switch-bg-color: var(--gray-300);
--switch-border-color: var(--gray-400);
--switch-toggle-color: #fff;
--switch-bg-color: var(--gray-300);
--switch-border-color: var(--gray-400);
--switch-toggle-color: #fff;
}
.form-group {
&:first-of-type {
margin-top: var(--unit-4);
}
&:not(:last-child) {
margin-bottom: var(--unit-4);
}
&:first-of-type {
margin-top: var(--unit-4);
}
&:not(:last-child) {
margin-bottom: var(--unit-4);
}
}
fieldset {
margin-bottom: var(--layout-spacing-lg);
margin-bottom: var(--layout-spacing-lg);
}
legend {
font-size: var(--font-size-lg);
font-weight: 500;
margin-bottom: var(--layout-spacing-lg);
font-size: var(--font-size-lg);
font-weight: 500;
margin-bottom: var(--layout-spacing-lg);
}
/* Form element: Label */
.form-label {
display: block;
line-height: var(--line-height);
margin-bottom: var(--unit-2);
font-weight: 500;
display: block;
line-height: var(--line-height);
margin-bottom: var(--unit-2);
font-weight: 500;
}
details summary .form-label {
margin-bottom: 0;
margin-bottom: 0;
}
details[open] summary .form-label {
margin-bottom: var(--unit-2);
margin-bottom: var(--unit-2);
}
/* Form element: Input */
.form-input {
appearance: none;
background: var(--input-bg-color);
background-image: none;
border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius);
box-shadow: var(--input-box-shadow);
color: var(--input-text-color);
display: block;
font-size: var(--font-size);
height: var(--control-size);
line-height: var(--line-height);
max-width: 100%;
outline: none;
padding: var(--control-padding-y) var(--control-padding-x);
position: relative;
transition: background 0.2s, border 0.2s, color 0.2s;
width: 100%;
appearance: none;
background: var(--input-bg-color);
background-image: none;
border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius);
box-shadow: var(--input-box-shadow);
color: var(--input-text-color);
display: block;
font-size: var(--font-size);
height: var(--control-size);
line-height: var(--line-height);
max-width: 100%;
outline: none;
padding: var(--control-padding-y) var(--control-padding-x);
position: relative;
transition:
background 0.2s,
border 0.2s,
color 0.2s;
width: 100%;
&:focus {
outline: var(--focus-outline);
outline-offset: calc(var(--focus-outline-offset) * -1);
}
&:focus {
outline: var(--focus-outline);
outline-offset: calc(var(--focus-outline-offset) * -1);
}
&::placeholder {
color: var(--input-placeholder-color);
opacity: 1;
}
&::placeholder {
color: var(--input-placeholder-color);
opacity: 1;
}
/* Input sizes */
/* Input sizes */
&.input-sm {
font-size: var(--font-size-sm);
height: var(--control-size-sm);
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
}
&.input-sm {
font-size: var(--font-size-sm);
height: var(--control-size-sm);
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
}
&.input-lg {
font-size: var(--font-size-lg);
height: var(--control-size-lg);
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
}
&.input-lg {
font-size: var(--font-size-lg);
height: var(--control-size-lg);
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
}
&.input-inline {
display: inline-block;
vertical-align: middle;
width: auto;
}
&.input-inline {
display: inline-block;
vertical-align: middle;
width: auto;
}
/* Input types */
/* Input types */
&[type="file"] {
height: auto;
}
&[type="file"] {
height: auto;
}
}
/* Form element: Textarea */
textarea.form-input {
&,
&.input-lg,
&.input-sm {
height: auto;
}
&,
&.input-lg,
&.input-sm {
height: auto;
}
}
/* Form element: Input hint */
.form-input-hint {
color: var(--input-hint-color);
font-size: var(--font-size-sm);
margin-top: var(--unit-1);
color: var(--input-hint-color);
font-size: var(--font-size-sm);
margin-top: var(--unit-1);
.has-success &,
.is-success + & {
color: var(--success-color);
}
.has-success &,
.is-success + & {
color: var(--success-color);
}
.has-error &,
.is-error + & {
color: var(--error-color);
}
.has-error &,
.is-error + & {
color: var(--error-color);
}
}
/* Form element: Select */
.form-select {
appearance: none;
background: var(--input-bg-color);
border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius);
box-shadow: var(--input-box-shadow);
color: var(--input-text-color);
font-size: var(--font-size);
height: var(--control-size);
line-height: var(--line-height);
outline: none;
appearance: none;
background: var(--input-bg-color);
border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius);
box-shadow: var(--input-box-shadow);
color: var(--input-text-color);
font-size: var(--font-size);
height: var(--control-size);
line-height: var(--line-height);
outline: none;
padding: var(--control-padding-y) var(--control-padding-x);
vertical-align: middle;
width: 100%;
&:focus {
outline: var(--focus-outline);
outline-offset: calc(var(--focus-outline-offset) * -1);
}
/* Select sizes */
&.select-sm {
font-size: var(--font-size-sm);
height: var(--control-size-sm);
padding: var(--control-padding-y-sm)
calc(var(--control-icon-size) + var(--control-padding-x-sm))
var(--control-padding-y-sm) var(--control-padding-x-sm);
}
&.select-lg {
font-size: var(--font-size-lg);
height: var(--control-size-lg);
padding: var(--control-padding-y-lg)
calc(var(--control-icon-size) + var(--control-padding-x-lg))
var(--control-padding-y-lg) var(--control-padding-x-lg);
}
/* Multiple select */
&[size],
&[multiple] {
height: auto;
padding: var(--control-padding-y) var(--control-padding-x);
vertical-align: middle;
width: 100%;
&:focus {
outline: var(--focus-outline);
outline-offset: calc(var(--focus-outline-offset) * -1);
& option {
padding: var(--unit-h) var(--unit-1);
}
}
/* Select sizes */
&:not([multiple]):not([size]) {
background: var(--input-bg-color)
url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E")
no-repeat right 0.35rem center / 0.4rem 0.5rem;
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
}
&.select-sm {
font-size: var(--font-size-sm);
height: var(--control-size-sm);
padding: var(--control-padding-y-sm) calc(var(--control-icon-size) + var(--control-padding-x-sm)) var(--control-padding-y-sm) var(--control-padding-x-sm);
}
&.select-lg {
font-size: var(--font-size-lg);
height: var(--control-size-lg);
padding: var(--control-padding-y-lg) calc(var(--control-icon-size) + var(--control-padding-x-lg)) var(--control-padding-y-lg) var(--control-padding-x-lg);
}
/* Multiple select */
&[size],
&[multiple] {
height: auto;
padding: var(--control-padding-y) var(--control-padding-x);
& option {
padding: var(--unit-h) var(--unit-1);
}
}
&:not([multiple]):not([size]) {
background: var(--input-bg-color) url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center / .4rem .5rem;
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
}
/* Options */
& option {
/* On Windows with Chrome / Edge, options seems to use the same
background color as the select. However for the dark theme the
background is a semi-transparent white, resulting in an opaque white
background for the dropdown. Use the modal background color to force
a dark background instead. */
background: var(--modal-container-bg-color);
}
}
/* Form element: Checkbox and Radio */
.form-checkbox,
.form-radio,
.form-switch {
display: block;
line-height: var(--line-height);
margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;
min-height: var(--control-size-sm);
padding: calc((var(--control-size-sm) - var(--line-height)) / 2) var(--control-padding-x) calc((var(--control-size-sm) - var(--line-height)) / 2) calc(var(--control-icon-size) + var(--control-padding-x));
position: relative;
display: block;
line-height: var(--line-height);
margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;
min-height: var(--control-size-sm);
padding: calc((var(--control-size-sm) - var(--line-height)) / 2)
var(--control-padding-x)
calc((var(--control-size-sm) - var(--line-height)) / 2)
calc(var(--control-icon-size) + var(--control-padding-x));
position: relative;
input {
clip: rect(0, 0, 0, 0);
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
input {
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);
}
&:focus-visible + .form-icon {
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}
.form-icon {
border: var(--border-width) solid var(--checkbox-border-color);
box-shadow: var(--input-box-shadow);
cursor: pointer;
display: inline-block;
position: absolute;
transition: background .2s, border .2s, color .2s;
&:checked + .form-icon {
background: var(--checkbox-checked-bg-color);
border-color: var(--checkbox-checked-bg-color);
}
}
/* Input checkbox, radio, and switch sizes */
.form-icon {
border: var(--border-width) solid var(--checkbox-border-color);
box-shadow: var(--input-box-shadow);
cursor: pointer;
display: inline-block;
position: absolute;
transition:
background 0.2s,
border 0.2s,
color 0.2s;
}
&.input-sm {
font-size: var(--font-size-sm);
margin: 0;
}
/* Input checkbox, radio, and switch sizes */
&.input-lg {
font-size: var(--font-size-lg);
margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;
}
&.input-sm {
font-size: var(--font-size-sm);
margin: 0;
}
&.input-lg {
font-size: var(--font-size-lg);
margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;
}
}
.form-checkbox,
.form-radio {
.form-icon {
background: var(--checkbox-bg-color);
height: var(--control-icon-size);
left: 0;
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
width: var(--control-icon-size);
}
.form-icon {
background: var(--checkbox-bg-color);
height: var(--control-icon-size);
left: 0;
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
width: var(--control-icon-size);
}
}
.form-checkbox {
font-weight: 500;
font-weight: 500;
.form-icon {
border-radius: var(--border-radius);
.form-icon {
border-radius: var(--border-radius);
}
input {
&:checked + .form-icon {
&::before {
background-clip: padding-box;
border: var(--border-width-lg) solid var(--checkbox-icon-color);
border-left-width: 0;
border-top-width: 0;
content: "";
height: 9px;
left: 50%;
margin-left: -3px;
margin-top: -6px;
position: absolute;
top: 50%;
transform: rotate(45deg);
width: 6px;
}
}
input {
&:checked + .form-icon {
&::before {
background-clip: padding-box;
border: var(--border-width-lg) solid var(--checkbox-icon-color);
border-left-width: 0;
border-top-width: 0;
content: "";
height: 9px;
left: 50%;
margin-left: -3px;
margin-top: -6px;
position: absolute;
top: 50%;
transform: rotate(45deg);
width: 6px;
}
}
&:indeterminate + .form-icon {
background: var(--checkbox-checked-bg-color);
border-color: var(--checkbox-checked-bg-color);
&:indeterminate + .form-icon {
background: var(--checkbox-checked-bg-color);
border-color: var(--checkbox-checked-bg-color);
&::before {
background: var(--checkbox-icon-color);
content: "";
height: 2px;
left: 50%;
margin-left: -5px;
margin-top: -1px;
position: absolute;
top: 50%;
width: 10px;
}
}
&::before {
background: var(--checkbox-icon-color);
content: "";
height: 2px;
left: 50%;
margin-left: -5px;
margin-top: -1px;
position: absolute;
top: 50%;
width: 10px;
}
}
}
}
.form-radio {
.form-icon {
border-radius: 50%;
}
.form-icon {
border-radius: 50%;
}
input {
&:checked + .form-icon {
&::before {
background: var(--checkbox-icon-color);
border-radius: 50%;
content: "";
height: 6px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
}
}
input {
&:checked + .form-icon {
&::before {
background: var(--checkbox-icon-color);
border-radius: 50%;
content: "";
height: 6px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
}
}
}
}
/* Form element: Switch */
.form-switch {
padding-left: calc(var(--unit-8) + var(--control-padding-x));
padding-left: calc(var(--unit-8) + var(--control-padding-x));
.form-icon {
background: var(--switch-bg-color);
background-clip: padding-box;
border-color: var(--switch-border-color);
border-radius: calc(var(--unit-2) + var(--border-width));
height: calc(var(--unit-4) + var(--border-width) * 2);
left: 0;
top: calc((var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width));
width: var(--unit-8);
.form-icon {
background: var(--switch-bg-color);
background-clip: padding-box;
border-color: var(--switch-border-color);
border-radius: calc(var(--unit-2) + var(--border-width));
height: calc(var(--unit-4) + var(--border-width) * 2);
left: 0;
top: calc(
(var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width)
);
width: var(--unit-8);
&::before {
background: var(--switch-toggle-color);
border-radius: 50%;
content: "";
display: block;
height: var(--unit-4);
left: 0;
position: absolute;
top: 0;
transition: background .2s, border .2s, color .2s, left .2s;
width: var(--unit-4);
}
&::before {
background: var(--switch-toggle-color);
border-radius: 50%;
content: "";
display: block;
height: var(--unit-4);
left: 0;
position: absolute;
top: 0;
transition:
background 0.2s,
border 0.2s,
color 0.2s,
left 0.2s;
width: var(--unit-4);
}
}
input {
&:checked + .form-icon {
&::before {
left: 14px;
}
}
input {
&:checked + .form-icon {
&::before {
left: 14px;
}
}
}
}
/* Form Icons */
.has-icon-left,
.has-icon-right {
position: relative;
position: relative;
.form-icon {
height: var(--control-icon-size);
margin: 0 var(--control-padding-y);
position: absolute;
top: 50%;
transform: translateY(-50%);
width: var(--control-icon-size);
z-index: calc(var(--zindex-0) + 1);
}
.form-icon {
height: var(--control-icon-size);
margin: 0 var(--control-padding-y);
position: absolute;
top: 50%;
transform: translateY(-50%);
width: var(--control-icon-size);
z-index: calc(var(--zindex-0) + 1);
}
}
.has-icon-left {
& .form-icon {
left: var(--border-width);
}
& .form-icon {
left: var(--border-width);
}
& .form-input {
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
}
& .form-input {
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
}
}
.has-icon-right {
& .form-icon {
right: var(--border-width);
}
& .form-icon {
right: var(--border-width);
}
& .form-input {
padding-right: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
}
& .form-input {
padding-right: calc(
var(--control-icon-size) + var(--control-padding-y) * 2
);
}
}
/* Form element: Input groups */
.input-group {
display: flex;
display: flex;
.input-group-addon {
background: var(--body-color);
border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius);
line-height: var(--line-height);
padding: var(--control-padding-y) var(--control-padding-x);
white-space: nowrap;
.input-group-addon {
background: var(--body-color);
border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius);
line-height: var(--line-height);
padding: var(--control-padding-y) var(--control-padding-x);
white-space: nowrap;
&.addon-sm {
font-size: var(--font-size-sm);
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
}
&.addon-lg {
font-size: var(--font-size-lg);
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
}
&.addon-sm {
font-size: var(--font-size-sm);
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
}
.form-input,
.form-select {
flex: 1 1 auto;
width: 1%;
&.addon-lg {
font-size: var(--font-size-lg);
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
}
}
.form-input,
.form-select {
flex: 1 1 auto;
width: 1%;
}
.input-group-btn {
z-index: var(--zindex-0);
}
.form-input,
.form-select,
.input-group-addon,
.input-group-btn {
&:first-child:not(:last-child) {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
.input-group-btn {
z-index: var(--zindex-0);
&:not(:first-child):not(:last-child) {
border-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
.form-input,
.form-select,
.input-group-addon,
.input-group-btn {
&:first-child:not(:last-child) {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
&:not(:first-child):not(:last-child) {
border-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
&:last-child:not(:first-child) {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
&:focus {
z-index: calc(var(--zindex-0) + 1);
}
&:last-child:not(:first-child) {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
margin-left: calc(-1 * var(--border-width));
}
.form-select {
width: auto;
&:focus {
z-index: calc(var(--zindex-0) + 1);
}
}
&.input-inline {
display: inline-flex;
}
.form-select {
width: auto;
}
&.input-inline {
display: inline-flex;
}
}
/* Form validation states */
.form-input,
.form-select {
.has-success &,
&.is-success {
background: var(--success-color-shade);
border-color: var(--success-color);
.has-success &,
&.is-success {
background: var(--success-color-shade);
border-color: var(--success-color);
&:focus {
outline-color: var(--success-color);
}
&:focus {
outline-color: var(--success-color);
}
}
.has-error &,
&.is-error {
background: var(--error-color-shade);
border-color: var(--error-color);
.has-error &,
&.is-error {
background: var(--error-color-shade);
border-color: var(--error-color);
&:focus {
outline-color: var(--error-color);
}
&:focus {
outline-color: var(--error-color);
}
}
}
/* Form disabled and readonly */
.form-input,
.form-select {
&:disabled,
&.disabled {
background-color: var(--input-disabled-bg-color);
cursor: not-allowed;
}
&:disabled,
&.disabled {
background-color: var(--input-disabled-bg-color);
cursor: not-allowed;
}
}
input {
&:disabled,
&.disabled {
& + .form-icon {
background: var(--checkbox-disabled-bg-color);
cursor: not-allowed;
}
&:disabled,
&.disabled {
& + .form-icon {
background: var(--checkbox-disabled-bg-color);
cursor: not-allowed;
}
}
}
/* Increase input font size on small viewports to prevent zooming on focus the input */
/* on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max */
/* viewport size */
@media screen and (max-width: 430px) {
.form-input {
font-size: 16px;
}
.form-input {
font-size: 16px;
}
}

View File

@@ -1,89 +1,90 @@
:root {
--menu-bg-color: var(--body-color);
--menu-border-color: var(--gray-200);
--menu-border-radius: var(--border-radius);
--menu-box-shadow: var(--box-shadow);
--menu-item-color: var(--text-color);
--menu-item-hover-color: var(--primary-text-color);
--menu-item-bg-color: transparent;
--menu-item-hover-bg-color: var(--primary-color-shade);
--menu-bg-color: var(--body-color);
--menu-border-color: var(--gray-200);
--menu-border-radius: var(--border-radius);
--menu-box-shadow: var(--box-shadow);
--menu-item-color: var(--text-color);
--menu-item-hover-color: var(--primary-text-color);
--menu-item-bg-color: transparent;
--menu-item-hover-bg-color: var(--primary-color-shade);
}
/* Menus */
.menu {
background: var(--menu-bg-color);
border: solid 1px var(--menu-border-color);
border-radius: var(--menu-border-radius);
box-shadow: var(--menu-box-shadow);
list-style: none;
margin: 0;
min-width: var(--control-width-xs);
transform: translateY(var(--layout-spacing-sm));
z-index: var(--zindex-3);
background: var(--menu-bg-color);
border: solid 1px var(--menu-border-color);
border-radius: var(--menu-border-radius);
box-shadow: var(--menu-box-shadow);
list-style: none;
margin: 0;
min-width: var(--control-width-xs);
transform: translateY(var(--layout-spacing-sm));
z-index: var(--zindex-3);
&.menu-nav {
background: transparent;
box-shadow: none;
&.menu-nav {
background: transparent;
box-shadow: none;
}
.menu-item {
margin-top: 0;
padding: 0 var(--unit-4);
position: relative;
text-decoration: none;
&:first-of-type {
padding-top: var(--unit-2);
}
.menu-item {
margin-top: 0;
padding: 0 var(--unit-4);
position: relative;
text-decoration: none;
&:first-of-type {
padding-top: var(--unit-2);
}
&:last-of-type {
padding-bottom: var(--unit-2);
}
& > a, .btn.btn-link {
border-radius: var(--menu-border-radius);
color: var(--menu-item-color);
background: var(--menu-item-bg-color);
display: block;
margin: 0 calc(-1 * var(--unit-2));
padding: var(--unit-1) var(--unit-2);
text-decoration: none;
&:focus,
&:hover,
&:active,
&.active {
background: var(--menu-item-hover-bg-color);
color: var(--menu-item-hover-color);
}
}
.form-checkbox,
.form-radio,
.form-switch {
margin: var(--unit-h) 0;
}
& + .menu-item {
margin-top: var(--unit-1);
}
&:last-of-type {
padding-bottom: var(--unit-2);
}
& .menu-badge {
align-items: center;
display: flex;
height: 100%;
position: absolute;
right: 0;
top: 0;
& > a,
.btn.btn-link {
border-radius: var(--menu-border-radius);
color: var(--menu-item-color);
background: var(--menu-item-bg-color);
display: block;
margin: 0 calc(-1 * var(--unit-2));
padding: var(--unit-1) var(--unit-2);
text-decoration: none;
.label {
margin-right: var(--unit-2);
}
&:focus,
&:hover,
&:active,
&.active {
background: var(--menu-item-hover-bg-color);
color: var(--menu-item-hover-color);
}
}
& .divider {
border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-2) 0;
.form-checkbox,
.form-radio,
.form-switch {
margin: var(--unit-h) 0;
}
}
& + .menu-item {
margin-top: var(--unit-1);
}
}
& .menu-badge {
align-items: center;
display: flex;
height: 100%;
position: absolute;
right: 0;
top: 0;
.label {
margin-right: var(--unit-2);
}
}
& .divider {
border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-2) 0;
}
}

View File

@@ -1,103 +1,104 @@
/* Modals */
:root {
--modal-overlay-bg-color: rgba(243, 244, 246, 0.6);
--modal-container-bg-color: var(--body-color);
--modal-container-border-color: var(--gray-200);
--modal-border-radius: var(--border-radius-lg);
--modal-box-shadow: var(--box-shadow-lg);
--modal-overlay-bg-color: rgba(243, 244, 246, 0.6);
--modal-container-bg-color: var(--body-color);
--modal-container-border-color: var(--gray-200);
--modal-border-radius: var(--border-radius-lg);
--modal-box-shadow: var(--box-shadow-lg);
}
.modal {
align-items: center;
bottom: 0;
display: none;
justify-content: center;
left: 0;
opacity: 0;
overflow: hidden;
padding: var(--layout-spacing);
position: fixed;
right: 0;
top: 0;
align-items: center;
bottom: 0;
display: none;
justify-content: center;
left: 0;
opacity: 0;
overflow: hidden;
padding: var(--layout-spacing);
position: fixed;
right: 0;
top: 0;
&:target,
&.active {
display: flex;
opacity: 1;
z-index: var(--zindex-4);
&:target,
&.active {
display: flex;
opacity: 1;
z-index: var(--zindex-4);
& .modal-overlay {
animation: fade-in .15s ease 1;
background: var(--modal-overlay-bg-color);
bottom: 0;
cursor: default;
display: block;
left: 0;
position: absolute;
right: 0;
top: 0;
}
& .modal-container {
animation: fade-in .15s ease 1;
z-index: var(--zindex-0);
}
& .modal-overlay {
animation: fade-in 0.15s ease 1;
background: var(--modal-overlay-bg-color);
bottom: 0;
cursor: default;
display: block;
left: 0;
position: fixed;
right: 0;
top: 0;
}
&.active.closing {
& .modal-overlay, & .modal-container {
animation: fade-out .15s ease 1;
}
& .modal-container {
animation: fade-in 0.15s ease 1;
z-index: var(--zindex-0);
}
}
&.active.closing {
& .modal-overlay,
& .modal-container {
animation: fade-out 0.15s ease 1;
}
}
}
.modal-container {
background: var(--modal-container-bg-color);
border: solid 1px var(--modal-container-border-color);
border-radius: var(--modal-border-radius);
box-shadow: var(--modal-box-shadow);
background: var(--modal-container-bg-color);
border: solid 1px var(--modal-container-border-color);
border-radius: var(--modal-border-radius);
box-shadow: var(--modal-box-shadow);
display: flex;
flex-direction: column;
gap: var(--unit-4);
max-height: 75vh;
max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%;
& .modal-header {
display: flex;
flex-direction: column;
gap: var(--unit-4);
max-height: 75vh;
max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%;
align-items: flex-start;
gap: var(--unit-2);
color: var(--text-color);
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
color: var(--text-color);
& h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
& button.close {
background: none;
border: none;
padding: 0;
line-height: 0;
cursor: pointer;
opacity: .85;
color: var(--secondary-text-color);
&:hover {
opacity: 1;
}
}
& h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
& .modal-body {
overflow-y: auto;
position: relative;
}
& button.close {
background: none;
border: none;
padding: 0;
line-height: 0;
cursor: pointer;
opacity: 0.85;
color: var(--secondary-text-color);
& .modal-footer {
text-align: right;
&:hover {
opacity: 1;
}
}
}
& .modal-body {
overflow-y: auto;
position: relative;
}
& .modal-footer {
text-align: right;
}
}

View File

@@ -1,61 +1,61 @@
/* Pagination */
.pagination {
display: flex;
list-style: none;
margin: var(--unit-1) 0;
padding: var(--unit-1) 0;
display: flex;
list-style: none;
margin: var(--unit-1) 0;
padding: var(--unit-1) 0;
& .page-item {
margin: var(--unit-1) var(--unit-o);
& .page-item {
margin: var(--unit-1) var(--unit-o);
& span {
display: inline-block;
padding: var(--unit-1) var(--unit-1);
}
& a {
border-radius: var(--border-radius);
display: inline-block;
padding: var(--unit-1) var(--unit-2);
text-decoration: none;
&:focus,
&:hover {
color: var(--primary-text-color);
}
}
&.disabled {
& a {
cursor: default;
opacity: .5;
pointer-events: none;
}
}
&.active {
& a {
background: var(--primary-color);
color: var(--contrast-text-color);
}
}
&.page-prev,
&.page-next {
flex: 1 0 50%;
}
&.page-next {
text-align: right;
}
& .page-item-title {
margin: 0;
}
& .page-item-subtitle {
margin: 0;
opacity: .5;
}
& span {
display: inline-block;
padding: var(--unit-1) var(--unit-1);
}
& a {
border-radius: var(--border-radius);
display: inline-block;
padding: var(--unit-1) var(--unit-2);
text-decoration: none;
&:focus,
&:hover {
color: var(--primary-text-color);
}
}
&.disabled {
& a {
cursor: default;
opacity: 0.5;
pointer-events: none;
}
}
&.active {
& a {
background: var(--primary-color);
color: var(--contrast-text-color);
}
}
&.page-prev,
&.page-next {
flex: 1 0 50%;
}
&.page-next {
text-align: right;
}
& .page-item-title {
margin: 0;
}
& .page-item-subtitle {
margin: 0;
opacity: 0.5;
}
}
}

View File

@@ -1,26 +1,26 @@
/* Tables */
.table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
text-align: left;
border-collapse: collapse;
border-spacing: 0;
width: 100%;
text-align: left;
/* Scrollable tables */
/* Scrollable tables */
&.table-scroll {
display: block;
overflow-x: auto;
padding-bottom: 0.75rem;
white-space: nowrap;
}
&.table-scroll {
display: block;
overflow-x: auto;
padding-bottom: 0.75rem;
white-space: nowrap;
}
& td,
& th {
border-bottom: var(--border-width) solid var(--border-color);
padding: var(--unit-3) var(--unit-2);
}
& td,
& th {
border-bottom: var(--border-width) solid var(--border-color);
padding: var(--unit-3) var(--unit-2);
}
& th {
border-bottom-width: var(--border-width-lg);
}
}
& th {
border-bottom-width: var(--border-width-lg);
}
}

View File

@@ -1,75 +1,76 @@
/* Tabs */
:root {
--tab-color: var(--text-color);
--tab-hover-color: var(--primary-text-color);
--tab-active-color: var(--primary-text-color);
--tab-highlight-color: var(--primary-color);
--tab-color: var(--text-color);
--tab-hover-color: var(--primary-text-color);
--tab-active-color: var(--primary-text-color);
--tab-highlight-color: var(--primary-color);
}
.tab {
align-items: center;
border-bottom: var(--border-width) solid var(--border-color);
display: flex;
flex-wrap: wrap;
list-style: none;
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
align-items: center;
border-bottom: var(--border-width) solid var(--border-color);
display: flex;
flex-wrap: wrap;
list-style: none;
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
& .tab-item {
margin-top: 0;
& a {
border-bottom: var(--border-width-lg) solid transparent;
color: var(--tab-color);
display: block;
margin: 0 var(--unit-2) 0 0;
padding: var(--unit-2) var(--unit-1)
calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
text-decoration: none;
&:focus,
&:hover {
color: var(--tab-hover-color);
}
}
&.active a,
& a.active {
border-bottom-color: var(--tab-highlight-color);
color: var(--tab-active-color);
}
&.tab-action {
flex: 1 0 auto;
text-align: right;
}
& .btn-clear {
margin-top: calc(-1 * var(--unit-1));
}
}
&.tab-block {
& .tab-item {
margin-top: 0;
flex: 1 0 0;
text-align: center;
& a {
border-bottom: var(--border-width-lg) solid transparent;
color: var(--tab-color);
display: block;
margin: 0 var(--unit-2) 0 0;
padding: var(--unit-2) var(--unit-1) calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
text-decoration: none;
& a {
margin: 0;
}
&:focus,
&:hover {
color: var(--tab-hover-color);
}
}
&.active a,
& a.active {
border-bottom-color: var(--tab-highlight-color);
color: var(--tab-active-color);
}
&.tab-action {
flex: 1 0 auto;
text-align: right;
}
& .btn-clear {
margin-top: calc(-1 * var(--unit-1));
& .badge {
&[data-badge]::after {
position: absolute;
right: var(--unit-h);
top: var(--unit-h);
transform: translate(0, 0);
}
}
}
}
&.tab-block {
& .tab-item {
flex: 1 0 0;
text-align: center;
& a {
margin: 0;
}
& .badge {
&[data-badge]::after {
position: absolute;
right: var(--unit-h);
top: var(--unit-h);
transform: translate(0, 0);
}
}
}
&:not(.tab-block) {
& .badge {
padding-right: 0;
}
&:not(.tab-block) {
& .badge {
padding-right: 0;
}
}
}
}
}

View File

@@ -1,35 +1,35 @@
/* Toasts */
.toast {
background: var(--gray-600);
border-radius: var(--border-radius);
color: var(--contrast-text-color);
display: block;
padding: var(--layout-spacing);
width: 100%;
background: var(--gray-600);
border-radius: var(--border-radius);
color: var(--contrast-text-color);
display: block;
padding: var(--layout-spacing);
width: 100%;
&.toast-primary {
background: var(--primary-color);
}
&.toast-primary {
background: var(--primary-color);
}
&.toast-success {
background: var(--success-color);
}
&.toast-success {
background: var(--success-color);
}
&.toast-warning {
background: var(--warning-color);
}
&.toast-warning {
background: var(--warning-color);
}
&.toast-error {
background: var(--error-color);
}
&.toast-error {
background: var(--error-color);
}
.btn-clear {
margin: var(--unit-h);
}
.btn-clear {
margin: var(--unit-h);
}
p {
&:last-child {
margin-bottom: 0;
}
p {
&:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -6,11 +6,11 @@ h3,
h4,
h5,
h6 {
color: inherit;
font-weight: 500;
line-height: 1.2;
margin-bottom: 0.5em;
margin-top: 0;
color: inherit;
font-weight: 500;
line-height: 1.2;
margin-bottom: 0.5em;
margin-top: 0;
}
.h1,
.h2,
@@ -18,100 +18,100 @@ h6 {
.h4,
.h5,
.h6 {
font-weight: 500;
font-weight: 500;
}
h1,
.h1 {
font-size: 2rem;
font-size: 2rem;
}
h2,
.h2 {
font-size: 1.6rem;
font-size: 1.6rem;
}
h3,
.h3 {
font-size: 1.4rem;
font-size: 1.4rem;
}
h4,
.h4 {
font-size: 1.2rem;
font-size: 1.2rem;
}
h5,
.h5 {
font-size: 1rem;
font-size: 1rem;
}
h6,
.h6 {
font-size: 0.8rem;
font-size: 0.8rem;
}
/* Paragraphs */
p {
margin: 0 0 var(--line-height);
margin: 0 0 var(--line-height);
}
/* Semantic text elements */
a,
ins,
u {
text-decoration-skip-ink: auto;
text-decoration-skip-ink: auto;
}
abbr[title] {
border-bottom: var(--border-width) dotted;
cursor: help;
text-decoration: none;
border-bottom: var(--border-width) dotted;
cursor: help;
text-decoration: none;
}
/* Blockquote */
blockquote {
border-left: var(--border-width-lg) solid var(--border-color);
margin-left: 0;
padding: var(--unit-2) var(--unit-4);
border-left: var(--border-width-lg) solid var(--border-color);
margin-left: 0;
padding: var(--unit-2) var(--unit-4);
& p:last-child {
margin-bottom: 0;
}
& p:last-child {
margin-bottom: 0;
}
}
/* Lists */
ul,
ol {
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
padding: 0;
& ul,
& ol {
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
padding: 0;
}
& ul,
& ol {
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
}
& li {
margin-top: var(--unit-2);
}
& li {
margin-top: var(--unit-2);
}
}
ul {
list-style: disc inside;
list-style: disc inside;
& ul {
list-style-type: circle;
}
& ul {
list-style-type: circle;
}
}
ol {
list-style: decimal inside;
list-style: decimal inside;
& ol {
list-style-type: lower-alpha;
}
& ol {
list-style-type: lower-alpha;
}
}
dl {
& dt {
font-weight: bold;
}
& dt {
font-weight: bold;
}
& dd {
margin: var(--unit-1) 0 var(--unit-4) 0;
}
& dd {
margin: var(--unit-1) 0 var(--unit-4) 0;
}
}

View File

@@ -1,296 +1,296 @@
/* Colors */
.text-primary {
color: var(--primary-text-color);
color: var(--primary-text-color);
}
.text-secondary {
color: var(--secondary-text-color);
color: var(--secondary-text-color);
}
.text-tertiary {
color: var(--tertiary-text-color);
color: var(--tertiary-text-color);
}
.text-success {
color: var(--success-color);
color: var(--success-color);
}
.text-warning {
color: var(--warning-color);
color: var(--warning-color);
}
.text-error {
color: var(--error-color);
color: var(--error-color);
}
.icon-color {
color: var(--icon-color);
color: var(--icon-color);
}
/* Display */
.d-block {
display: block;
display: block;
}
.d-inline {
display: inline;
display: inline;
}
.d-inline-block {
display: inline-block;
display: inline-block;
}
.d-flex {
display: flex;
display: flex;
}
.d-inline-flex {
display: inline-flex;
display: inline-flex;
}
.d-none,
.d-hide {
display: none !important;
display: none !important;
}
.d-visible {
visibility: visible;
visibility: visible;
}
.d-invisible {
visibility: hidden;
visibility: hidden;
}
.text-hide {
background: transparent;
border: 0;
color: transparent;
font-size: 0;
line-height: 0;
text-shadow: none;
background: transparent;
border: 0;
color: transparent;
font-size: 0;
line-height: 0;
text-shadow: none;
}
.text-assistive {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
/* Loading */
.loading {
color: transparent !important;
min-height: var(--unit-4);
pointer-events: none;
position: relative;
color: transparent !important;
min-height: var(--unit-4);
pointer-events: none;
position: relative;
&::after {
animation: loading 500ms infinite linear;
background: transparent;
border: var(--border-width-lg) solid var(--primary-color);
border-radius: 50%;
border-right-color: transparent;
border-top-color: transparent;
content: "";
display: block;
height: var(--unit-4);
left: 50%;
margin-left: calc(-1 * var(--unit-2));
margin-top: calc(-1 * var(--unit-2));
opacity: 1;
padding: 0;
position: absolute;
top: 50%;
width: var(--unit-4);
z-index: var(--zindex-0);
}
&.loading-lg {
min-height: var(--unit-10);
&::after {
animation: loading 500ms infinite linear;
background: transparent;
border: var(--border-width-lg) solid var(--primary-color);
border-radius: 50%;
border-right-color: transparent;
border-top-color: transparent;
content: "";
display: block;
height: var(--unit-4);
left: 50%;
margin-left: calc(-1 * var(--unit-2));
margin-top: calc(-1 * var(--unit-2));
opacity: 1;
padding: 0;
position: absolute;
top: 50%;
width: var(--unit-4);
z-index: var(--zindex-0);
}
&.loading-lg {
min-height: var(--unit-10);
&::after {
height: var(--unit-8);
margin-left: calc(-1 * var(--unit-4));
margin-top: calc(-1 * var(--unit-4));
width: var(--unit-8);
}
height: var(--unit-8);
margin-left: calc(-1 * var(--unit-4));
margin-top: calc(-1 * var(--unit-4));
width: var(--unit-8);
}
}
}
/* Position */
.m-0 {
margin: 0 !important;
margin: 0 !important;
}
.mb-0 {
margin-bottom: 0 !important;
margin-bottom: 0 !important;
}
.ml-0 {
margin-left: 0 !important;
margin-left: 0 !important;
}
.mr-0 {
margin-right: 0 !important;
margin-right: 0 !important;
}
.mt-0 {
margin-top: 0 !important;
margin-top: 0 !important;
}
.mx-0 {
margin-left: 0 !important;
margin-right: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.my-0 {
margin-bottom: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
margin-top: 0 !important;
}
.m-1 {
margin: var(--unit-1) !important;
margin: var(--unit-1) !important;
}
.mb-1 {
margin-bottom: var(--unit-1) !important;
margin-bottom: var(--unit-1) !important;
}
.ml-1 {
margin-left: var(--unit-1) !important;
margin-left: var(--unit-1) !important;
}
.mr-1 {
margin-right: var(--unit-1) !important;
margin-right: var(--unit-1) !important;
}
.mt-1 {
margin-top: var(--unit-1) !important;
margin-top: var(--unit-1) !important;
}
.mx-1 {
margin-left: var(--unit-1) !important;
margin-right: var(--unit-1) !important;
margin-left: var(--unit-1) !important;
margin-right: var(--unit-1) !important;
}
.my-1 {
margin-bottom: var(--unit-1) !important;
margin-top: var(--unit-1) !important;
margin-bottom: var(--unit-1) !important;
margin-top: var(--unit-1) !important;
}
.m-2 {
margin: var(--unit-2) !important;
margin: var(--unit-2) !important;
}
.mb-2 {
margin-bottom: var(--unit-2) !important;
margin-bottom: var(--unit-2) !important;
}
.ml-2 {
margin-left: var(--unit-2) !important;
margin-left: var(--unit-2) !important;
}
.mr-2 {
margin-right: var(--unit-2) !important;
margin-right: var(--unit-2) !important;
}
.mt-2 {
margin-top: var(--unit-2) !important;
margin-top: var(--unit-2) !important;
}
.mx-2 {
margin-left: var(--unit-2) !important;
margin-right: var(--unit-2) !important;
margin-left: var(--unit-2) !important;
margin-right: var(--unit-2) !important;
}
.my-2 {
margin-bottom: var(--unit-2) !important;
margin-top: var(--unit-2) !important;
margin-bottom: var(--unit-2) !important;
margin-top: var(--unit-2) !important;
}
.m-4 {
margin: var(--unit-4) !important;
margin: var(--unit-4) !important;
}
.mb-4 {
margin-bottom: var(--unit-4) !important;
margin-bottom: var(--unit-4) !important;
}
.ml-4 {
margin-left: var(--unit-4) !important;
margin-left: var(--unit-4) !important;
}
.mr-4 {
margin-right: var(--unit-4) !important;
margin-right: var(--unit-4) !important;
}
.mt-4 {
margin-top: var(--unit-4) !important;
margin-top: var(--unit-4) !important;
}
.mx-4 {
margin-left: var(--unit-4) !important;
margin-right: var(--unit-4) !important;
margin-left: var(--unit-4) !important;
margin-right: var(--unit-4) !important;
}
.my-4 {
margin-bottom: var(--unit-4) !important;
margin-top: var(--unit-4) !important;
margin-bottom: var(--unit-4) !important;
margin-top: var(--unit-4) !important;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
margin-left: auto;
margin-right: auto;
}
/* Text */
.text-normal {
font-weight: normal;
font-weight: normal;
}
.text-bold {
font-weight: bold;
font-weight: bold;
}
.text-italic {
font-style: italic;
font-style: italic;
}
.text-large {
font-size: 1.2em;
font-size: 1.2em;
}
.text-small {
font-size: .9em;
font-size: 0.9em;
}
.text-tiny {
font-size: .8em;
font-size: 0.8em;
}
.text-muted {
opacity: .8;
opacity: 0.8;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Flex */
.align-baseline {
align-items: baseline;
align-items: baseline;
}
.align-center {
align-items: center;
align-items: center;
}
.justify-between {
justify-content: space-between;
justify-content: space-between;
}

View File

@@ -1,135 +1,149 @@
:root {
/* Color palette */
--gray-50: rgb(249, 250, 251);
--gray-100: rgb(243, 244, 246);
--gray-200: rgb(229, 231, 235);
--gray-300: rgb(209, 213, 219);
--gray-400: rgb(156, 163, 175);
--gray-500: rgb(107, 114, 128);
--gray-600: rgb(75, 85, 99);
--gray-700: rgb(55, 65, 81);
--gray-800: rgb(31, 41, 55);
--gray-900: rgb(17, 24, 39);
/* Color palette */
--gray-50: rgb(249, 250, 251);
--gray-100: rgb(243, 244, 246);
--gray-200: rgb(229, 231, 235);
--gray-300: rgb(209, 213, 219);
--gray-400: rgb(156, 163, 175);
--gray-500: rgb(107, 114, 128);
--gray-600: rgb(75, 85, 99);
--gray-700: rgb(55, 65, 81);
--gray-800: rgb(31, 41, 55);
--gray-900: rgb(17, 24, 39);
--primary-color: hsl(241, 63%, 59%);
--primary-color-highlight: hsl(241, 63%, 64%);
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
--primary-color: hsl(241, 63%, 59%);
--primary-color-highlight: hsl(241, 63%, 64%);
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
--alternative-color: hsl(179, 94%, 29%);
--alternative-color-dark: hsl(179, 94%, 22%);
--alternative-color: hsl(179, 94%, 29%);
--alternative-color-dark: hsl(179, 94%, 22%);
--success-color: hsl(142, 76%, 36%);
--success-color-highlight: hsl(142, 76%, 40%);
--success-color-shade: hsla(142, 76%, 36%, 0.1);
--success-color: hsl(142, 76%, 36%);
--success-color-highlight: hsl(142, 76%, 40%);
--success-color-shade: hsla(142, 76%, 36%, 0.1);
--warning-color: hsl(38, 92%, 50%);
--warning-color-highlight: hsl(38, 92%, 55%);
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
--warning-color: hsl(38, 92%, 50%);
--warning-color-highlight: hsl(38, 92%, 55%);
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
--error-color: hsl(0, 72%, 51%);
--error-color-highlight: hsl(0, 72%, 60%);
--error-color-shade: hsla(0, 72%, 51%, 0.1);
--error-color: hsl(0, 72%, 51%);
--error-color-highlight: hsl(0, 72%, 60%);
--error-color-shade: hsla(0, 72%, 51%, 0.1);
/* Core colors */
--text-color: var(--gray-700);
--secondary-text-color: var(--gray-500);
--tertiary-text-color: var(--gray-500);
--contrast-text-color: #fff;
--primary-text-color: hsl(241, 63%, 55%);
/* Core colors */
--text-color: var(--gray-700);
--secondary-text-color: var(--gray-500);
--tertiary-text-color: var(--gray-500);
--contrast-text-color: #fff;
--primary-text-color: hsl(241, 63%, 55%);
--link-color: var(--primary-text-color);
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
--link-color: var(--primary-text-color);
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
--icon-color: var(--gray-500);
--icon-color: var(--gray-500);
--border-color: var(--gray-300);
--secondary-border-color: var(--gray-200);
--border-color: var(--gray-300);
--secondary-border-color: var(--gray-200);
--body-color: #fff;
--body-color-contrast: var(--gray-100);
--body-color: #fff;
--body-color-contrast: var(--gray-100);
/* Fonts */
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
--fallback-font-family: "Helvetica Neue", sans-serif;
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC", "Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
--body-font-family: var(--base-font-family), var(--fallback-font-family);
/* Fonts */
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
Roboto;
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
monospace;
--fallback-font-family: "Helvetica Neue", sans-serif;
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
var(--fallback-font-family);
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
var(--fallback-font-family);
--body-font-family: var(--base-font-family), var(--fallback-font-family);
/* Unit sizes */
--unit-o: 0.05rem;
--unit-h: 0.1rem;
--unit-1: 0.2rem;
--unit-2: 0.4rem;
--unit-3: 0.6rem;
--unit-4: 0.8rem;
--unit-5: 1rem;
--unit-6: 1.2rem;
--unit-7: 1.4rem;
--unit-8: 1.6rem;
--unit-9: 1.8rem;
--unit-10: 2rem;
--unit-12: 2.4rem;
--unit-16: 3.2rem;
/* Unit sizes */
--unit-o: 0.05rem;
--unit-h: 0.1rem;
--unit-1: 0.2rem;
--unit-2: 0.4rem;
--unit-3: 0.6rem;
--unit-4: 0.8rem;
--unit-5: 1rem;
--unit-6: 1.2rem;
--unit-7: 1.4rem;
--unit-8: 1.6rem;
--unit-9: 1.8rem;
--unit-10: 2rem;
--unit-12: 2.4rem;
--unit-16: 3.2rem;
/* Font sizes */
--html-font-size: 20px;
--html-line-height: 1.5;
--font-size: 0.7rem;
--font-size-sm: 0.65rem;
--font-size-lg: 0.8rem;
--line-height: 1rem;
/* Font sizes */
--html-font-size: 20px;
--html-line-height: 1.5;
--font-size: 0.7rem;
--font-size-sm: 0.65rem;
--font-size-lg: 0.8rem;
--line-height: 1rem;
/* Sizes */
--layout-spacing: var(--unit-2);
--layout-spacing-sm: var(--unit-1);
--layout-spacing-lg: var(--unit-4);
--border-radius: var(--unit-1);
--border-radius-lg: var(--unit-2);
--border-width: var(--unit-o);
--border-width-lg: var(--unit-h);
--control-size: var(--unit-8);
--control-size-sm: var(--unit-6);
--control-size-lg: var(--unit-9);
--control-padding-x: var(--unit-2);
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
--control-padding-y: calc((var(--control-size) - var(--line-height)) / 2 - var(--border-width));
--control-padding-y-sm: calc((var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width));
--control-padding-y-lg: calc((var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width));
--control-icon-size: 0.8rem;
/* Sizes */
--layout-spacing: var(--unit-2);
--layout-spacing-sm: var(--unit-1);
--layout-spacing-lg: var(--unit-4);
--border-radius: var(--unit-1);
--border-radius-lg: var(--unit-2);
--border-width: var(--unit-o);
--border-width-lg: var(--unit-h);
--control-size: var(--unit-8);
--control-size-sm: var(--unit-6);
--control-size-lg: var(--unit-9);
--control-padding-x: var(--unit-2);
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
--control-padding-y: calc(
(var(--control-size) - var(--line-height)) / 2 - var(--border-width)
);
--control-padding-y-sm: calc(
(var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width)
);
--control-padding-y-lg: calc(
(var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width)
);
--control-icon-size: 0.8rem;
--control-width-xs: 180px;
--control-width-sm: 320px;
--control-width-md: 640px;
--control-width-lg: 960px;
--control-width-xl: 1280px;
--control-width-xs: 180px;
--control-width-sm: 320px;
--control-width-md: 640px;
--control-width-lg: 960px;
--control-width-xl: 1280px;
/* Responsive breakpoints */
--size-xs: 480px;
--size-sm: 600px;
--size-md: 840px;
--size-lg: 960px;
--size-xl: 1280px;
--size-2x: 1440px;
/* Responsive breakpoints */
--size-xs: 480px;
--size-sm: 600px;
--size-md: 840px;
--size-lg: 960px;
--size-xl: 1280px;
--size-2x: 1440px;
--responsive-breakpoint: var(--size-xs);
--responsive-breakpoint: var(--size-xs);
/* Z-index */
--zindex-0: 1;
--zindex-1: 100;
--zindex-2: 200;
--zindex-3: 300;
--zindex-4: 400;
/* Z-index */
--zindex-0: 1;
--zindex-1: 100;
--zindex-2: 200;
--zindex-3: 300;
--zindex-4: 400;
/* Focus */
--focus-outline: 2px solid var(--primary-color);
--focus-outline-offset: 2px;
/* Focus */
--focus-outline: 2px solid var(--primary-color);
--focus-outline-offset: 2px;
/* Shadows */
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Shadows */
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}

View File

@@ -142,14 +142,20 @@
{% endif %}
</div>
</div>
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
{% if bookmark_list.show_preview_images %}
{% if bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
{% else %}
<div class="preview-image placeholder">
<div class="img"/>
</div>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
<div class="bookmark-pagination">
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
{% pagination bookmark_list.bookmarks_page %}
</div>
{% endif %}

View File

@@ -3,12 +3,13 @@
<div class="bookmarks-form">
{% csrf_token %}
{{ form.website_title }}
{{ form.website_description }}
{{ form.auto_close|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
<div class="has-icon-right">
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
<i class="form-icon loading"></i>
</div>
{% if form.url.errors %}
<div class="form-input-hint">
{{ form.url.errors }}
@@ -29,44 +30,24 @@
<div class="form-input-hint auto-tags"></div>
{{ form.tag_string.errors }}
</div>
<div class="form-group has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="has-icon-right">
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
<i class="form-icon loading"></i>
<button type="button" class="btn btn-link form-icon" title="Edit title from website">
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
<path d="M16 5l3 3"/>
</svg>
<div class="form-group">
<div class="d-flex justify-between align-baseline">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="btn btn-link clear-button"
type="button">Clear
</button>
</div>
<div class="form-input-hint">
Optional, leave empty to use title from website.
</div>
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
{{ form.title.errors }}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
<i class="form-icon loading"></i>
<button type="button" class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
<path d="M16 5l3 3"/>
</svg>
<div class="d-flex justify-between align-baseline">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<button ld-clear-button data-for="{{ form.description.id_for_label }}" class="btn btn-link clear-button"
type="button">Clear
</button>
</div>
<div class="form-input-hint">
Optional, leave empty to use description from website.
</div>
{{ form.description|add_class:"form-input"|attr:"rows:3" }}
{{ form.description.errors }}
</div>
<div class="form-group">
@@ -76,10 +57,10 @@
</summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
</details>
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
{{ form.notes.errors }}
</div>
<div class="form-group">
@@ -119,9 +100,8 @@
</div>
<script type="application/javascript">
/**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
* - Pre-fill title and description with metadata from website as soon as URL changes
* - Show hint if URL is already bookmarked
* - Setup buttons that allow editing of scraped website values
*/
(function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
@@ -131,28 +111,22 @@
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editedBookmarkId = {{ bookmark_id }};
let isTitleModified = !!titleInput.value;
let isDescriptionModified = !!descriptionInput.value;
function toggleLoadingIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
function updatePlaceholder(input, value) {
if (value) {
input.setAttribute('placeholder', value);
} else {
input.removeAttribute('placeholder');
}
}
function updateInput(input, value) {
if (!input) {
return;
}
input.value = value;
input.dispatchEvent(new Event('value-changed'));
}
function updateCheckbox(input, value) {
@@ -163,10 +137,11 @@
}
function checkUrl() {
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
updatePlaceholder(titleInput, null);
updatePlaceholder(descriptionInput, null);
if (!urlInput.value) {
return;
}
toggleLoadingIcon(urlInput, true);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
@@ -174,16 +149,14 @@
.then(response => response.json())
.then(data => {
const metadata = data.metadata;
updatePlaceholder(titleInput, metadata.title);
updatePlaceholder(descriptionInput, metadata.description);
toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false);
toggleLoadingIcon(urlInput, false);
// Prefill form and display hint if URL is already bookmarked
// Display hint if URL is already bookmarked
const existingBookmark = data.bookmark;
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
if (existingBookmark && !editedBookmarkId) {
// Prefill form with existing bookmark data
if (existingBookmark) {
// Workaround: tag input will be replaced by tag autocomplete, so
// defer getting the input until we need it
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
@@ -197,7 +170,13 @@
updateCheckbox(unreadCheckbox, existingBookmark.unread);
updateCheckbox(sharedCheckbox, existingBookmark.shared);
} else {
bookmarkExistsHint.style['display'] = 'none';
// Update title and description with website metadata, unless they have been modified
if (!isTitleModified) {
updateInput(titleInput, metadata.title);
}
if (!isDescriptionModified) {
updateInput(descriptionInput, metadata.description);
}
}
// Preview auto tags
@@ -214,31 +193,16 @@
});
}
function setupEditAutoValueButton(input) {
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
if (!editAutoValueButton) return;
editAutoValueButton.addEventListener('click', function (event) {
event.preventDefault();
input.value = input.getAttribute('placeholder');
input.focus();
input.select();
});
}
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
// Fetch initial website data if we have a URL, and we are not editing an existing bookmark
// For existing bookmarks we get the website metadata through hidden inputs
if (urlInput.value && !editedBookmarkId) {
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
if (!editedBookmarkId) {
checkUrl();
}
urlInput.addEventListener('input', checkUrl);
// Set initial website title and description for edited bookmarks
if (editedBookmarkId) {
updatePlaceholder(titleInput, websiteTitleInput.value);
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
urlInput.addEventListener('input', checkUrl);
titleInput.addEventListener('input', () => {
isTitleModified = true;
});
descriptionInput.addEventListener('input', () => {
isDescriptionModified = true;
});
}
})();
</script>

View File

@@ -30,7 +30,7 @@
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style>
<link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}

View File

@@ -3,11 +3,11 @@
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown">
<div ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks
</button>
<ul class="menu">
<ul class="menu" role="list">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
</li>
@@ -49,7 +49,7 @@
</svg>
</button>
<!-- menu component -->
<ul class="menu">
<ul class="menu" role="list">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
</li>

View File

@@ -15,7 +15,8 @@
{% endfor %}
</form>
<div ld-dropdown class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<button type="button" aria-label="Search preferences"
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
@@ -41,8 +42,11 @@
</div>
{% endif %}
{% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
<label id="search-shared-label"
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
Shared filter
</label>
{% for radio in preferences_form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
@@ -53,8 +57,11 @@
</div>
{% endif %}
{% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
<label id="search-unread-label"
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
Unread filter
</label>
{% for radio in preferences_form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}

View File

@@ -101,6 +101,29 @@
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group{% if form.items_per_page.errors %} has-error{% endif %}">
<label for="{{ form.items_per_page.id_for_label }}" class="form-label">Items per page</label>
{{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }}
{% if form.items_per_page.errors %}
<div class="form-input-hint is-error">
{{ form.items_per_page.errors }}
</div>
{% else %}
{% endif %}
<div class="form-input-hint">
The number of bookmarks to display per page.
</div>
</div>
<div class="form-group">
<label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox">
{{ form.sticky_pagination }}
<i class="form-icon"></i> Sticky pagination
</label>
<div class="form-input-hint">
When enabled, the pagination controls will stick to the bottom of the screen, so that they are always
visible without having to scroll to the end of the page first.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
@@ -146,7 +169,7 @@ reddit.com/r/Music music reddit</pre>
Enabling this feature automatically downloads all missing favicons.
By default, this feature uses a <b>Google service</b> to download favicons.
If you don't want to use this service, check the <a
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
href="https://linkding.link/options/#ld_favicon_provider"
target="_blank">options documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up.
</div>
@@ -341,7 +364,7 @@ reddit.com/r/Music music reddit</pre>
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding#documentation"
<td><a href="https://linkding.link/"
target="_blank">Documentation</a></td>
</tr>
<tr>

View File

@@ -41,12 +41,11 @@ class BookmarkFactoryMixin:
title: str = None,
description: str = "",
notes: str = "",
website_title: str = "",
website_description: str = "",
web_archive_snapshot_url: str = "",
favicon_file: str = "",
preview_image_file: str = "",
added: datetime = None,
modified: datetime = None,
):
if title is None:
title = get_random_string(length=32)
@@ -59,15 +58,15 @@ class BookmarkFactoryMixin:
url = "https://example.com/" + unique_id
if added is None:
added = timezone.now()
if modified is None:
modified = timezone.now()
bookmark = Bookmark(
url=url,
title=title,
description=description,
notes=notes,
website_title=website_title,
website_description=website_description,
date_added=added,
date_modified=timezone.now(),
date_modified=modified,
owner=user,
is_archived=is_archived,
unread=unread,
@@ -324,6 +323,7 @@ class BookmarkHtmlTag:
title: str = "",
description: str = "",
add_date: str = "",
last_modified: str = "",
tags: str = "",
to_read: bool = False,
private: bool = True,
@@ -332,6 +332,7 @@ class BookmarkHtmlTag:
self.title = title
self.description = description
self.add_date = add_date
self.last_modified = last_modified
self.tags = tags
self.to_read = to_read
self.private = private
@@ -343,6 +344,7 @@ class ImportTestMixin:
<DT>
<A {f'HREF="{tag.href}"' if tag.href else ''}
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
{f'LAST_MODIFIED="{tag.last_modified}"' if tag.last_modified else ''}
{f'TAGS="{tag.tags}"' if tag.tags else ''}
TOREAD="{1 if tag.to_read else 0}"
PRIVATE="{1 if tag.private else 0}">

View File

@@ -14,6 +14,20 @@ class AutoTaggingTestCase(TestCase):
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_handles_invalid_urls(self):
script = """
example.com example
test.com test
"""
url = "https://"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
url = "example.com"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
def test_auto_tag_by_domain_works_with_port(self):
script = """
example.com example

View File

@@ -150,16 +150,8 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(title)
self.assertEqual(title.text.strip(), bookmark.title)
# with website title
bookmark = self.setup_bookmark(title="", website_title="Website title")
soup = self.get_index_details_modal(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
self.assertEqual(title.text.strip(), bookmark.website_title)
# with URL only
bookmark = self.setup_bookmark(title="", website_title="")
bookmark = self.setup_bookmark(title="")
soup = self.get_index_details_modal(bookmark)
title = soup.find("h2")
@@ -478,7 +470,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_description(self):
# without description
bookmark = self.setup_bookmark(description="", website_description="")
bookmark = self.setup_bookmark(description="")
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description")
@@ -491,15 +483,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description)
# with website description
bookmark = self.setup_bookmark(
description="", website_description="Website description"
)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.website_description)
def test_notes(self):
# without notes
bookmark = self.setup_bookmark()
@@ -522,8 +505,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
self.assertIsNotNone(edit_link)
details_url = reverse("bookmarks:index") + f"?details={bookmark.id}"
expected_url = "/bookmarks/1/edit?return_url=/bookmarks%3Fdetails%3D1"
expected_url = f"/bookmarks/{bookmark.id}/edit?return_url=/bookmarks%3Fdetails%3D{bookmark.id}"
self.assertEqual(expected_url, edit_link["href"])
def test_delete_button(self):

View File

@@ -26,6 +26,11 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
}
return {**form_data, **overrides}
def test_should_render_successfully(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
self.assertEqual(response.status_code, 200)
def test_should_edit_bookmark(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id})
@@ -46,6 +51,14 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(tags[0].name, "editedtag1")
self.assertEqual(tags[1].name, "editedtag2")
def test_should_return_422_with_invalid_form(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id, "url": ""})
response = self.client.post(
reverse("bookmarks:edit", args=[bookmark.id]), form_data
)
self.assertEqual(response.status_code, 422)
def test_should_edit_unread_state(self):
bookmark = self.setup_bookmark()
@@ -80,8 +93,6 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
title="edited title",
description="edited description",
notes="edited notes",
website_title="website title",
website_description="website description",
)
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
@@ -114,7 +125,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">
<textarea name="description" cols="40" rows="3" class="form-input" id="id_description">
{bookmark.description}
</textarea>
""",
@@ -130,21 +141,39 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
html,
)
self.assertInHTML(
f"""
<input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}">
""",
html,
def test_should_prevent_duplicate_urls(self):
edited_bookmark = self.setup_bookmark(url="http://example.com/edited")
existing_bookmark = self.setup_bookmark(url="http://example.com/existing")
other_user_bookmark = self.setup_bookmark(
url="http://example.com/other-user", user=User.objects.create_user("other")
)
self.assertInHTML(
f"""
<input type="hidden" name="website_description" id="id_website_description"
value="{bookmark.website_description}">
""",
html,
# if the URL isn't modified it's not a duplicate
form_data = self.create_form_data({"url": edited_bookmark.url})
response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 302)
# if the URL is already bookmarked by another user, it's not a duplicate
form_data = self.create_form_data({"url": other_user_bookmark.url})
response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 302)
# if the URL is already bookmarked by the same user, it's a duplicate
form_data = self.create_form_data({"url": existing_bookmark.url})
response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 422)
self.assertInHTML(
"<li>A bookmark with this URL already exists.</li>",
response.content.decode(),
)
edited_bookmark.refresh_from_db()
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()

View File

@@ -46,6 +46,11 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(tags[0].name, "tag1")
self.assertEqual(tags[1].name, "tag2")
def test_should_return_422_with_invalid_form(self):
form_data = self.create_form_data({"url": ""})
response = self.client.post(reverse("bookmarks:new"), form_data)
self.assertEqual(response.status_code, 422)
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({"unread": True})
@@ -96,7 +101,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
'<textarea name="description" class="form-input" cols="40" '
'rows="2" id="id_description">Example Site Description</textarea>',
'rows="3" id="id_description">Example Site Description</textarea>',
html,
)
@@ -115,6 +120,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
</summary>
<label for="id_notes" class="text-assistive">Notes</label>
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
</details>
""",
html,

View File

@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) == 0)
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
def assertUnmodifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
label = soup.find("label", string=lambda s: s and s.strip() == text)
self.assertEqual(label["class"], ["form-label"])
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
label = soup.find("label", string=lambda s: s and s.strip() == text)
self.assertEqual(label["class"], ["form-label", "text-bold"])
def test_search_form_inputs(self):
# Without params
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
# Without modifications
url = "/test"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
self.assertNotIn("badge", button["class"])
# With modifications
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle badge">',
rendered_template,
)
self.assertIn("badge", button["class"])
# Ignores non-preferences modifications
url = "/test?q=foo&user=john"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
self.assertNotIn("badge", button["class"])
def test_modified_labels(self):
# Without modifications
url = "/test"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified sort
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertModifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified shared
url = "/test?shared=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertModifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified unread
url = "/test?unread=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertModifiedLabel(rendered_template, "Unread filter")

View File

@@ -33,8 +33,6 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["title"] = bookmark.title
expectation["description"] = bookmark.description
expectation["notes"] = bookmark.notes
expectation["website_title"] = bookmark.website_title
expectation["website_description"] = bookmark.website_description
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["favicon_url"] = (
f"http://testserver/static/{bookmark.favicon_file}"
@@ -56,6 +54,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["date_modified"] = bookmark.date_modified.isoformat().replace(
"+00:00", "Z"
)
expectation["website_title"] = None
expectation["website_description"] = None
expectations.append(expectation)
for data in data_list:
@@ -87,6 +87,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_returns_none_for_website_title_and_description(self):
self.authenticate()
bookmark = self.setup_bookmark()
bookmark.website_title = "Website title"
bookmark.website_description = "Website description"
bookmark.save()
response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
)
self.assertIsNone(response.data["results"][0]["website_title"])
self.assertIsNone(response.data["results"][0]["website_description"])
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
@@ -382,6 +395,44 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data["tag_names"][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data["tag_names"][1]).count(), 1)
def test_create_bookmark_enhances_with_metadata_by_default(self):
self.authenticate()
data = {"url": "https://example.com/"}
with patch.object(website_loader, "load_website_metadata") as mock_load:
mock_load.return_value = WebsiteMetadata(
url="https://example.com/",
title="Website title",
description="Website description",
preview_image=None,
)
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(bookmark.title, "Website title")
self.assertEqual(bookmark.description, "Website description")
def test_create_bookmark_does_not_enhance_with_metadata_if_scraping_is_disabled(
self,
):
self.authenticate()
data = {"url": "https://example.com/"}
with patch.object(website_loader, "load_website_metadata") as mock_load:
mock_load.return_value = WebsiteMetadata(
url="https://example.com/",
title="Website title",
description="Website description",
preview_image=None,
)
self.post(
reverse("bookmarks:bookmark-list") + "?disable_scraping",
data,
status.HTTP_201_CREATED,
)
bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(bookmark.title, "")
self.assertEqual(bookmark.description, "")
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.authenticate()
@@ -429,7 +480,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate()
data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
self.post(
reverse("bookmarks:bookmark-list") + "?disable_scraping",
data,
status.HTTP_201_CREATED,
)
bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(data["url"], bookmark.url)
self.assertEqual("", bookmark.title)
self.assertEqual("", bookmark.description)
self.assertEqual("", bookmark.notes)
self.assertFalse(bookmark.is_archived)
self.assertFalse(bookmark.unread)
self.assertFalse(bookmark.shared)
self.assertBookmarkListEqual([], bookmark.tag_names)
def test_create_archived_bookmark(self):
self.authenticate()
@@ -535,6 +600,28 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"])
def test_update_bookmark_ignores_readonly_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {
"url": "https://example.com/updated",
"web_archive_snapshot_url": "test",
"website_title": "test",
"website_description": "test",
}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(data["url"], updated_bookmark.url)
self.assertNotEqual(
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
)
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
self.assertNotEqual(
data["website_description"], updated_bookmark.website_description
)
def test_update_bookmark_fails_without_required_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
@@ -543,19 +630,24 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
bookmark = self.setup_bookmark(
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
)
data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"])
self.assertEqual(updated_bookmark.title, "")
self.assertEqual(updated_bookmark.description, "")
self.assertEqual(updated_bookmark.notes, "")
self.assertEqual(updated_bookmark.tag_names, [])
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertEqual(updated_bookmark.notes, bookmark.notes)
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
self.assertEqual(updated_bookmark.unread, bookmark.unread)
self.assertEqual(updated_bookmark.shared, bookmark.shared)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_update_bookmark_unread_flag(self):
self.authenticate()
@@ -593,6 +685,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
def test_update_bookmark_should_prevent_duplicate_urls(self):
self.authenticate()
edited_bookmark = self.setup_bookmark(url="https://example.com/edited")
existing_bookmark = self.setup_bookmark(url="https://example.com/existing")
other_user_bookmark = self.setup_bookmark(
url="https://example.com/other", user=self.setup_user()
)
# if the URL isn't modified it's not a duplicate
data = {"url": edited_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
# if the URL is already bookmarked by another user, it's not a duplicate
data = {"url": other_user_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
# if the URL is already bookmarked by the same user, it's a duplicate
data = {"url": existing_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_patch_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
@@ -652,16 +767,42 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ["updated-tag-1", "updated-tag-2"])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
def test_patch_ignores_readonly_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {
"web_archive_snapshot_url": "test",
"website_title": "test",
"website_description": "test",
}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertNotEqual(
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
)
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
self.assertNotEqual(
data["website_description"], updated_bookmark.website_description
)
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark(
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, bookmark.url)
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertEqual(updated_bookmark.notes, bookmark.notes)
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
self.assertEqual(updated_bookmark.unread, bookmark.unread)
self.assertEqual(updated_bookmark.shared, bookmark.shared)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
@@ -775,18 +916,24 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"http://testserver/static/preview.png", bookmark_data["preview_image_url"]
)
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
def test_check_returns_scraped_metadata_if_url_is_bookmarked(self):
self.authenticate()
bookmark = self.setup_bookmark(
self.setup_bookmark(
url="https://example.com",
website_title="Existing title",
website_description="Existing description",
)
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
"https://example.com",
"Scraped metadata",
"Scraped description",
"https://example.com/preview.png",
)
mock_load_website_metadata.return_value = expected_metadata
url = reverse("bookmarks:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com")
response = self.get(
@@ -794,12 +941,11 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
metadata = response.data["metadata"]
mock_load_website_metadata.assert_not_called()
self.assertIsNotNone(metadata)
self.assertEqual(bookmark.url, metadata["url"])
self.assertEqual(bookmark.website_title, metadata["title"])
self.assertEqual(bookmark.website_description, metadata["description"])
self.assertIsNone(metadata["preview_image"])
self.assertEqual(expected_metadata.url, metadata["url"])
self.assertEqual(expected_metadata.title, metadata["title"])
self.assertEqual(expected_metadata.description, metadata["description"])
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
def test_check_returns_no_auto_tags_if_none_configured(self):
self.authenticate()
@@ -863,6 +1009,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
{url: "https://example.com/"},
expected_status_code=status.HTTP_404_NOT_FOUND,
)
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse(
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
@@ -872,6 +1019,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
{url: "https://example.com/"},
expected_status_code=status.HTTP_404_NOT_FOUND,
)
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)

View File

@@ -87,6 +87,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.put(url, data, expected_status_code=status.HTTP_200_OK)
def test_update_bookmark_only_updates_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {"url": "https://example.com"}
@@ -97,6 +107,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
def test_patch_bookmark_only_updates_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])

View File

@@ -171,6 +171,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertIsNotNone(preview_image)
self.assertEqual(preview_image["src"], url)
def assertPreviewImagePlaceholder(self, html: str):
soup = self.make_soup(html)
placeholder = soup.select_one(".preview-image.placeholder")
self.assertIsNotNone(placeholder)
def assertBookmarkURLCount(
self, html: str, bookmark: Bookmark, link_target: str = "_blank", count=0
):
@@ -210,7 +215,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertNotesToggle(self, html: str, count=1):
self.assertInHTML(
f"""
"""
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
@@ -314,7 +319,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
# contains description text, without leading/trailing whitespace
if has_description:
description_text = description.find("span", text=bookmark.description)
description_text = description.find("span", string=bookmark.description)
self.assertIsNotNone(description_text)
if not has_tags:
@@ -331,7 +336,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
for tag in bookmark.tags.all():
tag_link = tags.find("a", text=f"#{tag.name}")
tag_link = tags.find("a", string=f"#{tag.name}")
self.assertIsNotNone(tag_link)
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
@@ -400,7 +405,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
for tag in bookmark.tags.all():
tag_link = tags.find("a", text=f"#{tag.name}")
tag_link = tags.find("a", string=f"#{tag.name}")
self.assertIsNotNone(tag_link)
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
@@ -691,15 +696,15 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertPreviewImageHidden(html, bookmark)
def test_preview_image_should_be_hidden_when_there_is_no_preview_image(self):
def test_preview_image_shows_placeholder_when_there_is_no_preview_image(self):
profile = self.get_or_create_test_user().profile
profile.enable_preview_images = True
profile.save()
bookmark = self.setup_bookmark()
self.setup_bookmark()
html = self.render_template()
self.assertPreviewImageHidden(html, bookmark)
self.assertPreviewImagePlaceholder(html)
def test_favicon_should_be_visible_when_favicons_enabled(self):
profile = self.get_or_create_test_user().profile
@@ -955,3 +960,37 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertInHTML(
'<p class="empty-title h5">You have no bookmarks yet</p>', html
)
def test_pagination_is_not_sticky_by_default(self):
self.setup_bookmark()
html = self.render_template()
self.assertIn('<div class="bookmark-pagination">', html)
def test_pagination_is_sticky_when_enabled_in_profile(self):
self.setup_bookmark()
profile = self.get_or_create_test_user().profile
profile.sticky_pagination = True
profile.save()
html = self.render_template()
self.assertIn('<div class="bookmark-pagination sticky">', html)
def test_items_per_page_is_30_by_default(self):
self.setup_numbered_bookmarks(50)
html = self.render_template()
soup = self.make_soup(html)
bookmarks = soup.select("li[ld-bookmark-item]")
self.assertEqual(30, len(bookmarks))
def test_items_per_page_is_configurable(self):
self.setup_numbered_bookmarks(50)
profile = self.get_or_create_test_user().profile
profile.items_per_page = 10
profile.save()
html = self.render_template()
soup = self.make_soup(html)
bookmarks = soup.select("li[ld-bookmark-item]")
self.assertEqual(10, len(bookmarks))

View File

@@ -8,15 +8,9 @@ class BookmarkTestCase(TestCase):
def test_bookmark_resolved_title(self):
bookmark = Bookmark(
title="Custom title",
website_title="Website title",
url="https://example.com",
)
self.assertEqual(bookmark.resolved_title, "Custom title")
bookmark = Bookmark(
title="", website_title="Website title", url="https://example.com"
)
self.assertEqual(bookmark.resolved_title, "Website title")
bookmark = Bookmark(title="", website_title="", url="https://example.com")
bookmark = Bookmark(title="", url="https://example.com")
self.assertEqual(bookmark.resolved_title, "https://example.com")

View File

@@ -25,8 +25,8 @@ from bookmarks.services.bookmarks import (
share_bookmarks,
unshare_bookmarks,
upload_asset,
enhance_with_website_metadata,
)
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin
User = get_user_model()
@@ -37,22 +37,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.get_or_create_test_user()
def test_create_should_update_website_metadata(self):
def test_create_should_not_update_website_metadata(self):
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
"https://example.com",
"Website title",
"Website description",
"https://example.com/preview.png",
)
mock_load_website_metadata.return_value = expected_metadata
bookmark_data = Bookmark(
url="https://example.com",
title="Updated Title",
description="Updated description",
title="Initial Title",
description="Initial description",
unread=True,
shared=True,
is_archived=True,
@@ -62,10 +54,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
)
created_bookmark.refresh_from_db()
self.assertEqual(expected_metadata.title, created_bookmark.website_title)
self.assertEqual(
expected_metadata.description, created_bookmark.website_description
)
self.assertEqual("Initial Title", created_bookmark.title)
self.assertEqual("Initial description", created_bookmark.description)
mock_load_website_metadata.assert_not_called()
def test_create_should_update_existing_bookmark_with_same_url(self):
original_bookmark = self.setup_bookmark(
@@ -164,37 +155,28 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_web_archive_snapshot.assert_not_called()
def test_update_should_update_website_metadata_if_url_did_change(self):
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
"https://example.com/updated",
"Updated website title",
"Updated website description",
"https://example.com/preview.png",
)
mock_load_website_metadata.return_value = expected_metadata
bookmark = self.setup_bookmark()
bookmark.url = "https://example.com/updated"
update_bookmark(bookmark, "tag1,tag2", self.user)
bookmark.refresh_from_db()
mock_load_website_metadata.assert_called_once()
self.assertEqual(expected_metadata.title, bookmark.website_title)
self.assertEqual(
expected_metadata.description, bookmark.website_description
)
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
def test_update_should_not_update_website_metadata(self):
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
bookmark = self.setup_bookmark()
bookmark.title = "updated title"
update_bookmark(bookmark, "tag1,tag2", self.user)
bookmark.refresh_from_db()
self.assertEqual("updated title", bookmark.title)
mock_load_website_metadata.assert_not_called()
def test_update_should_not_update_website_metadata_if_url_did_change(self):
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
bookmark = self.setup_bookmark(title="initial title")
bookmark.url = "https://example.com/updated"
update_bookmark(bookmark, "tag1,tag2", self.user)
bookmark.refresh_from_db()
self.assertEqual("initial title", bookmark.title)
mock_load_website_metadata.assert_not_called()
def test_update_should_update_favicon(self):
@@ -914,3 +896,61 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertIsNone(asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
self.assertEqual("", asset.file)
def test_enhance_with_website_metadata(self):
bookmark = self.setup_bookmark(url="https://example.com")
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
mock_load_website_metadata.return_value = website_loader.WebsiteMetadata(
url="https://example.com",
title="Website title",
description="Website description",
preview_image=None,
)
# missing title and description
bookmark.title = ""
bookmark.description = ""
bookmark.save()
enhance_with_website_metadata(bookmark)
bookmark.refresh_from_db()
self.assertEqual("Website title", bookmark.title)
self.assertEqual("Website description", bookmark.description)
# missing title only
bookmark.title = ""
bookmark.description = "Initial description"
bookmark.save()
enhance_with_website_metadata(bookmark)
bookmark.refresh_from_db()
self.assertEqual("Website title", bookmark.title)
self.assertEqual("Initial description", bookmark.description)
# missing description only
bookmark.title = "Initial title"
bookmark.description = ""
bookmark.save()
enhance_with_website_metadata(bookmark)
bookmark.refresh_from_db()
self.assertEqual("Initial title", bookmark.title)
self.assertEqual("Website description", bookmark.description)
# metadata returns None
mock_load_website_metadata.return_value = website_loader.WebsiteMetadata(
url="https://example.com",
title=None,
description=None,
preview_image=None,
)
bookmark.title = ""
bookmark.description = ""
bookmark.save()
enhance_with_website_metadata(bookmark)
bookmark.refresh_from_db()
self.assertEqual("", bookmark.title)
self.assertEqual("", bookmark.description)

View File

@@ -1,21 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.client.force_login(self.get_or_create_test_user())
def test_does_not_render_custom_style_tag_by_default(self):
response = self.client.get(reverse("bookmarks:index"))
self.assertNotContains(response, "<style>")
def test_renders_custom_style_tag_if_user_has_custom_css(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(response, "<style>body { background-color: red; }</style>")

View File

@@ -0,0 +1,28 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_with_empty_css(self):
response = self.client.get(reverse("bookmarks:custom_css"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/css")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
self.assertEqual(response.content.decode(), "")
def test_with_custom_css(self):
css = "body { background-color: red; }"
self.user.profile.custom_css = css
self.user.profile.save()
response = self.client.get(reverse("bookmarks:custom_css"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/css")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
self.assertEqual(response.content.decode(), css)

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timezone
from django.test import TestCase
from django.utils import timezone
from bookmarks.services import exporter
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -7,20 +8,19 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
def test_export_bookmarks(self):
added = timezone.now()
timestamp = int(added.timestamp())
bookmarks = [
self.setup_bookmark(
url="https://example.com/1",
title="Title 1",
added=added,
added=datetime.fromtimestamp(1, timezone.utc),
modified=datetime.fromtimestamp(11, timezone.utc),
description="Example description",
),
self.setup_bookmark(
url="https://example.com/2",
title="Title 2",
added=added,
added=datetime.fromtimestamp(2, timezone.utc),
modified=datetime.fromtimestamp(22, timezone.utc),
tags=[
self.setup_tag(name="tag1"),
self.setup_tag(name="tag2"),
@@ -28,15 +28,24 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
],
),
self.setup_bookmark(
url="https://example.com/3", title="Title 3", added=added, unread=True
url="https://example.com/3",
title="Title 3",
added=datetime.fromtimestamp(3, timezone.utc),
modified=datetime.fromtimestamp(33, timezone.utc),
unread=True,
),
self.setup_bookmark(
url="https://example.com/4", title="Title 4", added=added, shared=True
url="https://example.com/4",
title="Title 4",
added=datetime.fromtimestamp(4, timezone.utc),
modified=datetime.fromtimestamp(44, timezone.utc),
shared=True,
),
self.setup_bookmark(
url="https://example.com/5",
title="Title 5",
added=added,
added=datetime.fromtimestamp(5, timezone.utc),
modified=datetime.fromtimestamp(55, timezone.utc),
shared=True,
description="Example description",
notes="Example notes",
@@ -44,20 +53,23 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(
url="https://example.com/6",
title="Title 6",
added=added,
added=datetime.fromtimestamp(6, timezone.utc),
modified=datetime.fromtimestamp(66, timezone.utc),
shared=True,
notes="Example notes",
),
self.setup_bookmark(
url="https://example.com/7",
title="Title 7",
added=added,
added=datetime.fromtimestamp(7, timezone.utc),
modified=datetime.fromtimestamp(77, timezone.utc),
is_archived=True,
),
self.setup_bookmark(
url="https://example.com/8",
title="Title 8",
added=added,
added=datetime.fromtimestamp(8, timezone.utc),
modified=datetime.fromtimestamp(88, timezone.utc),
tags=[self.setup_tag(name="tag4"), self.setup_tag(name="tag5")],
is_archived=True,
),
@@ -65,17 +77,17 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
html = exporter.export_netscape_html(bookmarks)
lines = [
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
'<DT><A HREF="https://example.com/1" ADD_DATE="1" LAST_MODIFIED="11" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
"<DD>Example description",
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
f'<DT><A HREF="https://example.com/5" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
'<DT><A HREF="https://example.com/2" ADD_DATE="2" LAST_MODIFIED="22" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
'<DT><A HREF="https://example.com/3" ADD_DATE="3" LAST_MODIFIED="33" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
'<DT><A HREF="https://example.com/4" ADD_DATE="4" LAST_MODIFIED="44" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
'<DT><A HREF="https://example.com/5" ADD_DATE="5" LAST_MODIFIED="55" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
"<DD>Example description[linkding-notes]Example notes[/linkding-notes]",
f'<DT><A HREF="https://example.com/6" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
'<DT><A HREF="https://example.com/6" ADD_DATE="6" LAST_MODIFIED="66" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
"<DD>[linkding-notes]Example notes[/linkding-notes]",
f'<DT><A HREF="https://example.com/7" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
f'<DT><A HREF="https://example.com/8" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
'<DT><A HREF="https://example.com/7" ADD_DATE="7" LAST_MODIFIED="77" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
'<DT><A HREF="https://example.com/8" ADD_DATE="8" LAST_MODIFIED="88" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
]
self.assertIn("\n\r".join(lines), html)
@@ -98,7 +110,5 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark()
bookmark.title = ""
bookmark.description = ""
bookmark.website_title = None
bookmark.website_description = None
bookmark.save()
exporter.export_netscape_html([bookmark])

View File

@@ -31,11 +31,18 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
for tag in bookmark.tag_names:
categories.append(f"<category>{tag}</category>")
if bookmark.resolved_description:
expected_description = (
f"<description>{bookmark.resolved_description}</description>"
)
else:
expected_description = "<description/>"
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"{expected_description}"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
f"{''.join(categories)}"
@@ -63,7 +70,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
def test_all_returns_all_unarchived_bookmarks(self):
bookmarks = [
self.setup_bookmark(description="test description"),
self.setup_bookmark(website_description="test website description"),
self.setup_bookmark(description=""),
self.setup_bookmark(unread=True, description="test description"),
]
self.setup_bookmark(is_archived=True)
@@ -118,9 +125,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
unread_bookmarks = [
self.setup_bookmark(unread=True, description="test description"),
self.setup_bookmark(
unread=True, website_description="test website description"
),
self.setup_bookmark(unread=True, description=""),
self.setup_bookmark(unread=True, description="test description"),
]

View File

@@ -26,6 +26,9 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(bookmark.title, html_tag.title)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
self.assertEqual(
bookmark.date_modified, parse_timestamp(html_tag.last_modified)
)
self.assertEqual(bookmark.unread, html_tag.to_read)
self.assertEqual(bookmark.shared, not html_tag.private)
@@ -45,6 +48,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="11",
tags="example-tag",
),
BookmarkHtmlTag(
@@ -52,6 +56,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Foo title",
description="",
add_date="2",
last_modified="22",
tags="",
),
BookmarkHtmlTag(
@@ -59,6 +64,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Bar title",
description="Bar description",
add_date="3",
last_modified="33",
tags="bar-tag, other-tag",
),
BookmarkHtmlTag(
@@ -66,6 +72,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Baz title",
description="Baz description",
add_date="4",
last_modified="44",
to_read=True,
),
]
@@ -90,6 +97,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="11",
tags="example-tag",
),
BookmarkHtmlTag(
@@ -97,6 +105,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Foo title",
description="",
add_date="2",
last_modified="22",
tags="",
),
BookmarkHtmlTag(
@@ -104,20 +113,23 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Bar title",
description="Bar description",
add_date="3",
last_modified="33",
tags="bar-tag, other-tag",
),
BookmarkHtmlTag(
href="https://example.com/unread",
title="Unread title",
description="Unread description",
add_date="3",
add_date="4",
last_modified="44",
to_read=True,
),
BookmarkHtmlTag(
href="https://example.com/private",
title="Private title",
description="Private description",
add_date="4",
add_date="5",
last_modified="55",
private=True,
),
]
@@ -136,6 +148,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Updated Example title",
description="Updated Example description",
add_date="111",
last_modified="1111",
tags="updated-example-tag",
),
BookmarkHtmlTag(
@@ -143,6 +156,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Updated Foo title",
description="Updated Foo description",
add_date="222",
last_modified="2222",
tags="new-tag",
),
BookmarkHtmlTag(
@@ -150,6 +164,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Updated Bar title",
description="Updated Bar description",
add_date="333",
last_modified="3333",
tags="updated-bar-tag, updated-other-tag",
),
BookmarkHtmlTag(
@@ -157,6 +172,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Unread title",
description="Unread description",
add_date="3",
last_modified="3",
to_read=False,
),
BookmarkHtmlTag(
@@ -164,9 +180,15 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Private title",
description="Private description",
add_date="4",
last_modified="4",
private=False,
),
BookmarkHtmlTag(href="https://baz.com", add_date="444", tags="baz-tag"),
BookmarkHtmlTag(
href="https://baz.com",
add_date="444",
last_modified="4444",
tags="baz-tag",
),
]
# Import updated data
@@ -291,6 +313,19 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1)
)
def test_use_add_date_when_no_last_modified(self):
test_html = self.render_html(
tags_html=f"""
<DT><A HREF="https://example.com" ADD_DATE="1">Example.com</A>
<DD>Example.com
"""
)
import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].date_modified, parse_timestamp("1"))
def test_keep_title_if_imported_bookmark_has_empty_title(self):
test_html = self.render_html(
tags=[BookmarkHtmlTag(href="https://example.com", title="Example.com")]

View File

@@ -2,10 +2,10 @@ from django.test import TestCase
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class LayoutTestCase(TestCase, BookmarkFactoryMixin):
class LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -63,3 +63,38 @@ class LayoutTestCase(TestCase, BookmarkFactoryMixin):
html,
count=0,
)
def test_does_not_link_custom_css_when_empty(self):
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
soup = self.make_soup(html)
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
self.assertIsNone(link)
def test_does_link_custom_css_when_not_empty(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
soup = self.make_soup(html)
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
self.assertIsNotNone(link)
def test_custom_css_link_href(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
soup = self.make_soup(html)
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
expected_url = (
reverse("bookmarks:custom_css") + f"?hash={profile.custom_css_hash}"
)
self.assertEqual(link["href"], expected_url)

View File

@@ -1,7 +1,7 @@
from django.test import TestCase, override_settings
from django.urls import path, include
from bookmarks.tests.helpers import HtmlTestMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from siteroot.urls import urlpatterns as base_patterns
# Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled
@@ -9,13 +9,25 @@ urlpatterns = base_patterns + [path("oidc/", include("mozilla_django_oidc.urls")
@override_settings(ROOT_URLCONF=__name__)
class LoginViewTestCase(TestCase, HtmlTestMixin):
class LoginViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_failed_login_should_return_401(self):
response = self.client.post("/login/", {"username": "test", "password": "test"})
self.assertEqual(response.status_code, 401)
def test_successful_login_should_redirect(self):
user = self.setup_user(name="test")
user.set_password("test")
user.save()
response = self.client.post("/login/", {"username": "test", "password": "test"})
self.assertEqual(response.status_code, 302)
def test_should_not_show_oidc_login_by_default(self):
response = self.client.get("/login/")
soup = self.make_soup(response.content.decode())
oidc_login_link = soup.find("a", text="Login with OIDC")
oidc_login_link = soup.find("a", string="Login with OIDC")
self.assertIsNone(oidc_login_link)
@@ -24,6 +36,6 @@ class LoginViewTestCase(TestCase, HtmlTestMixin):
response = self.client.get("/login/")
soup = self.make_soup(response.content.decode())
oidc_login_link = soup.find("a", text="Login with OIDC")
oidc_login_link = soup.find("a", string="Login with OIDC")
self.assertIsNotNone(oidc_login_link)

View File

@@ -18,6 +18,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual(bookmark.href, html_tag.href)
self.assertEqual(bookmark.title, html_tag.title)
self.assertEqual(bookmark.date_added, html_tag.add_date)
self.assertEqual(bookmark.date_modified, html_tag.last_modified)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))
self.assertEqual(bookmark.to_read, html_tag.to_read)
@@ -30,6 +31,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="11",
tags="example-tag",
),
BookmarkHtmlTag(
@@ -37,6 +39,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Foo title",
description="",
add_date="2",
last_modified="22",
tags="",
),
BookmarkHtmlTag(
@@ -44,13 +47,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Bar title",
description="Bar description",
add_date="3",
last_modified="33",
tags="bar-tag, other-tag",
),
BookmarkHtmlTag(
href="https://example.com/baz",
title="Baz title",
description="Baz description",
add_date="3",
add_date="4",
to_read=True,
),
]
@@ -72,9 +76,17 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="1",
tags="example-tag",
),
BookmarkHtmlTag(href="", title="", description="", add_date="", tags=""),
BookmarkHtmlTag(
href="",
title="",
description="",
add_date="",
last_modified="",
tags="",
),
]
html = self.render_html(html_tags)
bookmarks = parse(html)

View File

@@ -43,6 +43,7 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse("change_password"), form_data)
self.assertEqual(response.status_code, 422)
self.assertIn("old_password", response.context_data["form"].errors)
def test_should_return_error_for_mismatching_new_password(self):
@@ -54,4 +55,5 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse("change_password"), form_data)
self.assertEqual(response.status_code, 422)
self.assertIn("new_password2", response.context_data["form"].errors)

View File

@@ -36,14 +36,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(description=random_sentence(including_word="TERM1")),
self.setup_bookmark(notes=random_sentence(including_word="term1")),
self.setup_bookmark(notes=random_sentence(including_word="TERM1")),
self.setup_bookmark(website_title=random_sentence(including_word="term1")),
self.setup_bookmark(website_title=random_sentence(including_word="TERM1")),
self.setup_bookmark(
website_description=random_sentence(including_word="term1")
),
self.setup_bookmark(
website_description=random_sentence(including_word="TERM1")
),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url="http://example.com/term1/term2"),
@@ -55,30 +47,16 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
description=random_sentence(including_word="term1"),
title=random_sentence(including_word="term2"),
),
self.setup_bookmark(
website_title=random_sentence(including_word="term1"),
title=random_sentence(including_word="term2"),
),
self.setup_bookmark(
website_description=random_sentence(including_word="term1"),
title=random_sentence(including_word="term2"),
),
]
self.tag1_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(title=random_sentence(), tags=[tag1]),
self.setup_bookmark(description=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_title=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1]),
]
self.tag1_as_term_bookmarks = [
self.setup_bookmark(url="http://example.com/tag1"),
self.setup_bookmark(title=random_sentence(including_word="tag1")),
self.setup_bookmark(description=random_sentence(including_word="tag1")),
self.setup_bookmark(website_title=random_sentence(including_word="tag1")),
self.setup_bookmark(
website_description=random_sentence(including_word="tag1")
),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url="http://example.com/term1", tags=[tag1]),
@@ -88,12 +66,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(
description=random_sentence(including_word="term1"), tags=[tag1]
),
self.setup_bookmark(
website_title=random_sentence(including_word="term1"), tags=[tag1]
),
self.setup_bookmark(
website_description=random_sentence(including_word="term1"), tags=[tag1]
),
]
self.tag2_bookmarks = [
self.setup_bookmark(tags=[tag2]),
@@ -136,22 +108,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(
notes=random_sentence(including_word="TERM1"), tags=[self.setup_tag()]
),
self.setup_bookmark(
website_title=random_sentence(including_word="term1"),
tags=[self.setup_tag()],
),
self.setup_bookmark(
website_title=random_sentence(including_word="TERM1"),
tags=[self.setup_tag()],
),
self.setup_bookmark(
website_description=random_sentence(including_word="term1"),
tags=[self.setup_tag()],
),
self.setup_bookmark(
website_description=random_sentence(including_word="TERM1"),
tags=[self.setup_tag()],
),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(
@@ -167,16 +123,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
title=random_sentence(including_word="term2"),
tags=[self.setup_tag()],
),
self.setup_bookmark(
website_title=random_sentence(including_word="term1"),
title=random_sentence(including_word="term2"),
tags=[self.setup_tag()],
),
self.setup_bookmark(
website_description=random_sentence(including_word="term1"),
title=random_sentence(including_word="term2"),
tags=[self.setup_tag()],
),
]
self.tag1_bookmarks = [
self.setup_bookmark(tags=[tag1, self.setup_tag()]),
@@ -184,21 +130,11 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(
description=random_sentence(), tags=[tag1, self.setup_tag()]
),
self.setup_bookmark(
website_title=random_sentence(), tags=[tag1, self.setup_tag()]
),
self.setup_bookmark(
website_description=random_sentence(), tags=[tag1, self.setup_tag()]
),
]
self.tag1_as_term_bookmarks = [
self.setup_bookmark(url="http://example.com/tag1"),
self.setup_bookmark(title=random_sentence(including_word="tag1")),
self.setup_bookmark(description=random_sentence(including_word="tag1")),
self.setup_bookmark(website_title=random_sentence(including_word="tag1")),
self.setup_bookmark(
website_description=random_sentence(including_word="tag1")
),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(
@@ -212,14 +148,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
description=random_sentence(including_word="term1"),
tags=[tag1, self.setup_tag()],
),
self.setup_bookmark(
website_title=random_sentence(including_word="term1"),
tags=[tag1, self.setup_tag()],
),
self.setup_bookmark(
website_description=random_sentence(including_word="term1"),
tags=[tag1, self.setup_tag()],
),
]
self.tag2_bookmarks = [
self.setup_bookmark(tags=[tag2, self.setup_tag()]),
@@ -1260,30 +1188,18 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title="A_1_2"),
self.setup_bookmark(title="b_1_1"),
self.setup_bookmark(title="B_1_2"),
self.setup_bookmark(title="", website_title="a_2_1"),
self.setup_bookmark(title="", website_title="A_2_2"),
self.setup_bookmark(title="", website_title="b_2_1"),
self.setup_bookmark(title="", website_title="B_2_2"),
self.setup_bookmark(title="", website_title="", url="a_3_1"),
self.setup_bookmark(title="", website_title="", url="A_3_2"),
self.setup_bookmark(title="", website_title="", url="b_3_1"),
self.setup_bookmark(title="", website_title="", url="B_3_2"),
self.setup_bookmark(title="a_4_1", website_title="0"),
self.setup_bookmark(title="A_4_2", website_title="0"),
self.setup_bookmark(title="b_4_1", website_title="0"),
self.setup_bookmark(title="B_4_2", website_title="0"),
self.setup_bookmark(title="", url="a_3_1"),
self.setup_bookmark(title="", url="A_3_2"),
self.setup_bookmark(title="", url="b_3_1"),
self.setup_bookmark(title="", url="B_3_2"),
self.setup_bookmark(title="a_5_1", url="0"),
self.setup_bookmark(title="A_5_2", url="0"),
self.setup_bookmark(title="b_5_1", url="0"),
self.setup_bookmark(title="B_5_2", url="0"),
self.setup_bookmark(title="", website_title="a_6_1", url="0"),
self.setup_bookmark(title="", website_title="A_6_2", url="0"),
self.setup_bookmark(title="", website_title="b_6_1", url="0"),
self.setup_bookmark(title="", website_title="B_6_2", url="0"),
self.setup_bookmark(title="a_7_1", website_title="0", url="0"),
self.setup_bookmark(title="A_7_2", website_title="0", url="0"),
self.setup_bookmark(title="b_7_1", website_title="0", url="0"),
self.setup_bookmark(title="B_7_2", website_title="0", url="0"),
self.setup_bookmark(title="", url="0"),
self.setup_bookmark(title="", url="0"),
self.setup_bookmark(title="", url="0"),
self.setup_bookmark(title="", url="0"),
]
return bookmarks

View File

@@ -1,3 +1,4 @@
import hashlib
import random
from unittest.mock import patch, Mock
@@ -22,6 +23,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
if not overrides:
overrides = {}
form_data = {
"update_profile": "",
"theme": UserProfile.THEME_AUTO,
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
@@ -43,6 +45,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"permanent_notes": False,
"custom_css": "",
"auto_tagging_rules": "",
"items_per_page": "30",
"sticky_pagination": False,
}
return {**form_data, **overrides}
@@ -111,6 +115,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"default_mark_unread": True,
"custom_css": "body { background-color: #000; }",
"auto_tagging_rules": "example.com tag",
"items_per_page": "10",
"sticky_pagination": True,
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
@@ -182,8 +188,21 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]
)
self.assertEqual(
self.user.profile.items_per_page, int(form_data["items_per_page"])
)
self.assertEqual(
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
)
self.assertSuccessMessage(html, "Profile updated")
def test_update_profile_with_invalid_form_returns_422(self):
form_data = self.create_profile_form_data({"items_per_page": "-1"})
response = self.client.post(reverse("bookmarks:settings.update"), form_data)
self.assertEqual(response.status_code, 422)
def test_update_profile_should_not_be_called_without_respective_form_action(self):
form_data = {
"theme": UserProfile.THEME_DARK,
@@ -199,6 +218,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
self.assertSuccessMessage(html, "Profile updated", count=0)
def test_update_profile_updates_custom_css_hash(self):
form_data = self.create_profile_form_data(
{
"custom_css": "body { background-color: #000; }",
}
)
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
self.user.profile.refresh_from_db()
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
form_data["custom_css"] = "body { background-color: #fff; }"
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
self.user.profile.refresh_from_db()
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
form_data["custom_css"] = ""
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
self.user.profile.refresh_from_db()
self.assertEqual("", self.user.profile.custom_css_hash)
def test_enable_favicons_should_schedule_icon_update(self):
with patch.object(
tasks, "schedule_bookmarks_without_favicons"
@@ -206,7 +250,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
# Enabling favicons schedules update
form_data = self.create_profile_form_data(
{
"update_profile": "",
"enable_favicons": True,
}
)
@@ -320,7 +363,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
# Enabling favicons schedules update
form_data = self.create_profile_form_data(
{
"update_profile": "",
"enable_preview_images": True,
}
)

View File

@@ -1,3 +1,4 @@
import secrets
import gzip
import os
import subprocess
@@ -9,9 +10,10 @@ from bookmarks.services import singlefile
class SingleFileServiceTestCase(TestCase):
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
html_filepath = "temp.html.gz"
temp_html_filepath = "temp.html.gz.tmp"
def setUp(self):
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
self.html_filepath = secrets.token_hex(8) + ".html.gz"
self.temp_html_filepath = self.html_filepath + ".tmp"
def tearDown(self):
if os.path.exists(self.html_filepath):
@@ -64,7 +66,7 @@ class SingleFileServiceTestCase(TestCase):
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
"http://example.com",
self.html_filepath + ".tmp",
]
@@ -86,7 +88,7 @@ class SingleFileServiceTestCase(TestCase):
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
"--some-option",
"some value",
"--another-option",

View File

@@ -65,4 +65,6 @@ urlpatterns = [
path("health", views.health, name="health"),
# Manifest
path("manifest.json", views.manifest, name="manifest"),
# Custom CSS
path("custom_css", views.custom_css, name="custom_css"),
]

View File

@@ -2,7 +2,7 @@ import logging
import re
import unicodedata
import urllib.parse
from datetime import datetime
import datetime
from typing import Optional
from dateutil.relativedelta import relativedelta
@@ -33,7 +33,9 @@ weekday_names = {
}
def humanize_absolute_date(value: datetime, now: Optional[datetime] = None):
def humanize_absolute_date(
value: datetime.datetime, now: Optional[datetime.datetime] = None
):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
@@ -51,7 +53,9 @@ def humanize_absolute_date(value: datetime, now: Optional[datetime] = None):
return weekday_names[value.isoweekday()]
def humanize_relative_date(value: datetime, now: Optional[datetime] = None):
def humanize_relative_date(
value: datetime.datetime, now: Optional[datetime.datetime] = None
):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
@@ -87,21 +91,21 @@ def parse_timestamp(value: str):
raise ValueError(f"{value} is not a valid timestamp")
try:
return datetime.utcfromtimestamp(timestamp).astimezone()
return datetime.datetime.fromtimestamp(timestamp, datetime.UTC)
except (OverflowError, ValueError, OSError):
pass
# Value exceeds the max. allowed timestamp
# Try parsing as microseconds
try:
return datetime.utcfromtimestamp(timestamp / 1000).astimezone()
return datetime.datetime.fromtimestamp(timestamp / 1000, datetime.UTC)
except (OverflowError, ValueError, OSError):
pass
# Value exceeds the max. allowed timestamp
# Try parsing as nanoseconds
try:
return datetime.utcfromtimestamp(timestamp / 1000000).astimezone()
return datetime.datetime.fromtimestamp(timestamp / 1000000, datetime.UTC)
except (OverflowError, ValueError, OSError):
pass

View File

@@ -4,4 +4,5 @@ from .settings import *
from .toasts import *
from .health import health
from .manifest import manifest
from .custom_css import custom_css
from .root import root

View File

@@ -38,8 +38,6 @@ from bookmarks.services.bookmarks import (
from bookmarks.utils import get_safe_return_url
from bookmarks.views import contexts, partials, turbo
_default_page_size = 30
@login_required
def index(request):
@@ -152,7 +150,6 @@ def convert_tag_string(tag_string: str):
@login_required
def new(request):
status = 200
initial_url = request.GET.get("url")
initial_title = request.GET.get("title")
initial_description = request.GET.get("description")
@@ -171,8 +168,6 @@ def new(request):
return HttpResponseRedirect(reverse("bookmarks:close"))
else:
return HttpResponseRedirect(reverse("bookmarks:index"))
else:
status = 422
else:
form = BookmarkForm()
if initial_url:
@@ -188,6 +183,7 @@ def new(request):
if initial_mark_unread:
form.initial["unread"] = "true"
status = 422 if request.method == "POST" and not form.is_valid() else 200
context = {
"form": form,
"auto_close": initial_auto_close,
@@ -218,9 +214,10 @@ def edit(request, bookmark_id: int):
form.initial["tag_string"] = build_tag_string(bookmark.tag_names, " ")
status = 422 if request.method == "POST" and not form.is_valid() else 200
context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url}
return render(request, "bookmarks/edit.html", context)
return render(request, "bookmarks/edit.html", context, status=status)
def remove(request, bookmark_id: int):

View File

@@ -21,7 +21,6 @@ from bookmarks.models import (
)
from bookmarks.services.wayback import generate_fallback_webarchive_url
DEFAULT_PAGE_SIZE = 30
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
@@ -181,7 +180,7 @@ class BookmarkListContext:
query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page")
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
paginator = Paginator(query_set, user_profile.items_per_page)
bookmarks_page = paginator.get_page(page_number)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")

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