Compare commits

..

96 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
Sascha Ißbrücker
2b3cd2dec1 Bump version 2024-09-16 13:21:39 +02:00
itz-Jana
c22e30cbda Implement IPv6 capability (#826)
* Implement IPv6 capability

Enables uWSGI to listen for IPv6 requests also.
This is done by defaulting to [::] as the listen address, which creates a dual stack socket, which can respond to IPv4 and IPv6 requests simultaneously.
Furthermore a config option is adden to overwrite this default, if a user so desires.

* Add LD_SERVER_HOST to .env.sample

Additionally fix the default name of the LD_SERVER_PORT variable, which was falsely LD_HOST_PORT here.

* revert .env.sample

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-09-16 13:18:18 +02:00
Sascha Ißbrücker
ffaaf0521d Speed up response times for certain actions (#829)
* return updated HTML from bookmark actions

* open details through URL

* fix details update

* improve modal behavior

* use a frame

* make behaviors properly destroy themselves

* remove page and details params from tag urls

* use separate behavior for details and tags

* remove separate details view

* make it work with other views

* add asset actions

* remove asset refresh for now

* remove details partial

* fix tests

* remove old partials

* update tests

* cache and reuse tags

* extract search autocomplete behavior

* remove details param from pagination

* fix tests

* only return details modal when navigating in frame

* fix link target

* remove unused behaviors

* use auto submit behavior for user select

* fix import
2024-09-16 12:48:19 +02:00
Sascha Ißbrücker
db225d5267 Fix several issues around browser back navigation (#825) 2024-09-15 08:28:49 +02:00
Sascha Ißbrücker
74e65bc366 Theme cleanup 2024-09-14 18:55:02 +02:00
Sascha Ißbrücker
edba98f1fe Update CHANGELOG.md 2024-09-14 12:23:10 +02:00
Sascha Ißbrücker
785fe32aaa Bump version 2024-09-14 12:06:32 +02:00
dependabot[bot]
5559ad0070 Bump svelte from 4.2.12 to 4.2.19 (#806)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.12 to 4.2.19.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.19/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.19/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  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-14 11:43:49 +02:00
Leonhard Markert
76c65566cf Rename "SingeFileError" to "SingleFileError" (#823) 2024-09-14 11:37:03 +02:00
Sascha Ißbrücker
c929e8f11c Speed up navigation (#824)
* use client-side navigation

* update tests

* add setting for enabling link prefetching

* do not prefetch bookmark details

* theme progress bar

* cleanup behaviors

* update test
2024-09-14 11:32:19 +02:00
Sascha Ißbrücker
3ae9cf0420 Theme improvements (#822)
* start converting

* small fixes

* reorganize theme files

* cleanup search bar

* increase spacing

* small tweaks

* fix select styles in Chrome

* cleanup menus

* improve button icons

* restore badges

* remove unused classes

* restore some overrides

* restore bookmark form

* add summary outline

* avoid layout shifts

* restore bookmark details

* increase border radius for modals

* improve details modal

* restore reader mode

* restore settings

* cleanup variables

* start with dark theme

* more dark theme...

* more light theme...

* more dark theme...

* add postcss build

* remove sass processor

* update docker build

* fix alt color

* remove endless symbol

* fix tests

* update assets

* remove sass files

* fix docker build

* cleanup spacing

* improve theme

* update test scripts

* update CI workflow

* fix test
2024-09-13 23:19:47 +02:00
Sascha Ißbrücker
b736464f3f Bump version 2024-09-10 21:39:48 +02:00
Sascha Ißbrücker
7572aa5bc9 Fix auto-tagging when URL includes port (#820) 2024-09-10 21:19:20 +02:00
Sascha Ißbrücker
cb0301fd9e Fix inconsistent tag order in bookmarks (#819) 2024-09-10 21:06:57 +02:00
Sascha Ißbrücker
b30486317d Allow pre-filling notes in new bookmark form (#812) 2024-08-31 23:20:44 +02:00
Sascha Ißbrücker
1c6e5902db Additional filter parameters for RSS feeds (#811) 2024-08-31 22:58:41 +02:00
Sascha Ißbrücker
20fe88dd57 Return bookmark tags in RSS feeds (#810) 2024-08-31 22:41:22 +02:00
Sascha Ißbrücker
aad62f61c9 Allow configuring guest user profile (#809) 2024-08-31 20:25:43 +02:00
Sascha Ißbrücker
79bf4b38c6 remove unused context processor 2024-08-31 19:10:42 +02:00
Sascha Ißbrücker
5eadb3ede3 Allow configuring landing page for unauthenticated users (#808)
* allow configuring landing page

* add tests
2024-08-31 15:39:22 +02:00
Sascha Ißbrücker
36749c398b Update CHANGELOG.md 2024-08-30 19:51:39 +02:00
Sascha Ißbrücker
190b5aeeca Bump version 2024-08-30 18:20:58 +02:00
Sascha Ißbrücker
1122d18e18 Show web archive fallback link in details modal 2024-08-29 23:39:07 +02:00
Sascha Ißbrücker
0fe6304328 Fix overflow in settings page (#805) 2024-08-29 23:04:11 +02:00
Sascha Ißbrücker
7d4e65976f Run tests in parallel 2024-08-29 22:45:43 +02:00
Sascha Ißbrücker
749bc1ef63 Generate fallback URLs for web archive links (#804)
* generate fallback web archive URL if none exists

* remove fallback web archive snapshot creation

* fix test
2024-08-29 22:45:10 +02:00
Casey Link
36a84276a2 Add OCI source annotation to link back to source repo (#701)
* Add OCI source annotation to link back to source repo

This commit adds the `org.opencontainers.image.source` label to the
built container images.

This label is helpful for tools to be able to link back from the
container image to the source repo.

For example, for those that use Renovate to help auto update
dependencies, this will result in the latest releases release
notes/changelog being included in the PR which is very handy!

* move label to base image

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-08-28 22:57:58 +02:00
Howard Wilson
b72697b819 Allow use of standard docker TZ env var (#765)
* Allow use of standard docker TZ env var

* use getenv api

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-08-28 22:57:15 +02:00
Meng Sen
d9362c9b9c Add resource linkding logo (#788)
* Add resource linkding logo

If you need to use the icon, you can download ` logo.png` yourself. If you need to limit the size, you can use `logo.svg` to convert it to png.

* move to assets

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-08-28 22:38:32 +02:00
dependabot[bot]
b0610db406 Bump urllib3 from 2.1.0 to 2.2.2 (#762)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.1.0 to 2.2.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.1.0...2.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 22:30:19 +02:00
dependabot[bot]
af16a9e727 Bump djangorestframework from 3.14.0 to 3.15.2 (#769)
Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.14.0 to 3.15.2.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.2)

---
updated-dependencies:
- dependency-name: djangorestframework
  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-08-28 22:30:09 +02:00
dependabot[bot]
d898c1be4d Bump certifi from 2023.11.17 to 2024.7.4 (#775)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 22:29:37 +02:00
dependabot[bot]
0282220307 Bump django from 5.0.3 to 5.0.8 (#795)
Bumps [django](https://github.com/django/django) from 5.0.3 to 5.0.8.
- [Commits](https://github.com/django/django/compare/5.0.3...5.0.8)

---
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>
2024-08-28 22:29:08 +02:00
VolumeData21
bb243b382d removed version line from docker compose yaml (#800)
Co-authored-by: andrew <110792083+beboprocky@users.noreply.github.com>
2024-08-28 22:28:47 +02:00
Filipe Belatti
fbc97a3841 Add Pinkt to the Community section (#772)
Pinkt is an Android app which added support for Linkding starting from version 3.0
2024-07-02 21:28:57 +02:00
Sascha Ißbrücker
380f5ed19c Include favicons and thumbnails in REST API (#763)
* Include favicons and thumbnails in REST API

* Fix serialization for custom endpoints
2024-06-18 23:07:14 +02:00
Sascha Ißbrücker
b28352fb28 Update CHANGELOG.md 2024-06-16 22:45:01 +02:00
255 changed files with 18409 additions and 4719 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

@@ -10,6 +10,7 @@
!/manage.py
!/package.json
!/package-lock.json
!/postcss.config.js
!/requirements.dev.txt
!/requirements.txt
!/rollup.config.mjs

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:
@@ -53,7 +53,6 @@ jobs:
- name: Run build
run: |
npm run build
python manage.py compilescss
python manage.py collectstatic --ignore=*.scss
python manage.py collectstatic
- name: Run tests
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"

4
.gitignore vendored
View File

@@ -183,7 +183,7 @@ typings/
### Custom
# Rollup compilation output
/bookmarks/static/bundle.js*
# SASS compilation output
# CSS compilation output
/bookmarks/static/theme-*.css*
# Collected static files for deployment
/static
@@ -192,7 +192,7 @@ typings/
# Database file
/data
# ublock + chromium
/uBlock0.chromium
/uBOLite.chromium.mv3
/chromium-profile
# direnv
/.direnv

View File

@@ -1,5 +1,147 @@
# 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
* Theme improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/822
* Speed up navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/824
* Rename "SingeFileError" to "SingleFileError" by @curiousleo in https://github.com/sissbruecker/linkding/pull/823
* Bump svelte from 4.2.12 to 4.2.19 by @dependabot in https://github.com/sissbruecker/linkding/pull/806
### New Contributors
* @curiousleo made their first contribution in https://github.com/sissbruecker/linkding/pull/823
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.32.0...v1.33.0
---
## v1.32.0 (10/09/2024)
### What's Changed
* Allow configuring landing page for unauthenticated users by @sissbruecker in https://github.com/sissbruecker/linkding/pull/808
* Allow configuring guest user profile by @sissbruecker in https://github.com/sissbruecker/linkding/pull/809
* Return bookmark tags in RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/810
* Additional filter parameters for RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/811
* Allow pre-filling notes in new bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/812
* Fix inconsistent tag order in bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/819
* Fix auto-tagging when URL includes port by @sissbruecker in https://github.com/sissbruecker/linkding/pull/820
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.1...v1.32.0
---
## v1.31.1 (30/08/2024)
### What's Changed
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
### New Contributors
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
---
## v1.31.0 (16/06/2024)
### What's Changed
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
### New Contributors
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
---
## v1.30.0 (20/04/2024)
### What's Changed

View File

@@ -7,9 +7,10 @@ tasks:
python manage.py process_tasks
test:
pytest
pytest -n auto
format:
black bookmarks
black siteroot
npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write

231
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,218 +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)
- [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
@@ -304,7 +105,7 @@ The frontend is now available under http://localhost:8000
Run all tests with pytest:
```
pytest
make test
```
### Formatting
@@ -316,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:

BIN
assets/header.afdesign Normal file

Binary file not shown.

BIN
assets/header.png Normal file

Binary file not shown.

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

BIN
assets/logo-inset.afdesign Normal file

Binary file not shown.

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

17
assets/logo.svg Normal file
View File

@@ -0,0 +1,17 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,3 +1,5 @@
import logging
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
@@ -19,6 +21,8 @@ from bookmarks.services.bookmarks import (
)
from bookmarks.services.website_loader import WebsiteMetadata
logger = logging.getLogger(__name__)
class BookmarkViewSet(
viewsets.GenericViewSet,
@@ -52,7 +56,12 @@ class BookmarkViewSet(
return Bookmark.objects.all().filter(owner=user)
def get_serializer_context(self):
return {"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):
@@ -60,8 +69,8 @@ class BookmarkViewSet(
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["get"], detail=False)
@@ -73,8 +82,8 @@ class BookmarkViewSet(
user, request.user_profile, search, public_only
)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["post"], detail=True)
@@ -97,22 +106,19 @@ 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
auto_tags = []
if profile.auto_tagging_rules:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
try:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={url}",
exc_info=e,
)
return Response(
{

View File

@@ -1,9 +1,14 @@
from django.db.models import prefetch_related_objects
from django.templatetags.static import static
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
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
@@ -28,60 +33,102 @@ class BookmarkSerializer(serializers.ModelSerializer):
"title",
"description",
"notes",
"website_title",
"website_description",
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
"is_archived",
"unread",
"shared",
"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:
return None
request = self.context.get("request")
favicon_file_path = static(obj.favicon_file)
favicon_url = request.build_absolute_uri(favicon_file_path)
return favicon_url
def get_preview_image_url(self, obj: Bookmark):
if not obj.preview_image_file:
return None
request = self.context.get("request")
preview_image_file_path = static(obj.preview_image_file)
preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url
def 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

@@ -18,19 +18,5 @@ def toasts(request):
}
def public_shares(request):
# Only check for public shares for anonymous users
if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(
None, request.user_profile, BookmarkSearch(), True
)
has_public_shares = query_set.count() > 0
return {
"has_public_shares": has_public_shares,
}
return {}
def app_version(request):
return {"app_version": utils.app_version}

View File

@@ -121,8 +121,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
with self.page.expect_navigation():
details_modal.get_by_text("Edit").click()
# Cancel edit, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
# Cancel edit, verify return to details url
details_url = url + f"&details={bookmark.id}"
with self.page.expect_navigation(url=self.live_server_url + details_url):
self.page.get_by_text("Nevermind").click()
def test_delete(self):
@@ -134,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()
@@ -167,7 +171,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Has new snapshots
expect(snapshot).to_be_visible()
# Create snapshot
# Remove snapshot
asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click()

View File

@@ -1,37 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
def test_edit_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
# Navigate to edit page
with self.page.expect_navigation():
self.page.get_by_text("Edit").click()
# Cancel edit, verify return url
with self.page.expect_navigation(
url=self.live_server_url
+ reverse("bookmarks:details", args=[bookmark.id])
):
self.page.get_by_text("Nevermind").click()
def test_delete_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
# Trigger delete, verify return url
# Should probably return to last bookmark list page, but for now just returns to index
with self.page.expect_navigation(
url=self.live_server_url + reverse("bookmarks:index")
):
self.page.get_by_text("Delete...").click()
self.page.get_by_text("Confirm").click()

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

@@ -1,9 +1,7 @@
from django.test import override_settings
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect, Locator
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
@@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
# verify modal is visible
modal = page.locator(".modal")
expect(modal).to_be_visible()
expect(modal.locator(".modal-title")).to_have_text("Tags")
expect(modal.locator("h2")).to_have_text("Tags")
# close with close button
modal.locator("button.close").click()

View File

@@ -2,7 +2,8 @@ import unicodedata
from dataclasses import dataclass
from django.contrib.syndication.views import Feed
from django.db.models import QuerySet
from django.db.models import QuerySet, prefetch_related_objects
from django.http import HttpRequest
from django.urls import reverse
from bookmarks import queries
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass
class FeedContext:
request: HttpRequest
feed_token: FeedToken | None
query_set: QuerySet[Bookmark]
@@ -26,13 +28,27 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
search = BookmarkSearch(
q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""),
)
return FeedContext(feed_token, query_set)
query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set)
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
raise NotImplementedError
def items(self, context: FeedContext):
limit = context.request.GET.get("limit", 100)
if limit:
data = context.query_set[: int(limit)]
else:
data = list(context.query_set)
prefetch_related_objects(data, "tags")
return data
def item_title(self, item: Bookmark):
return sanitize(item.resolved_title)
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
def item_pubdate(self, item: Bookmark):
return item.date_added
def item_categories(self, item: Bookmark):
return item.tag_names
class AllBookmarksFeed(BaseBookmarksFeed):
title = "All bookmarks"
description = "All bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class UnreadBookmarksFeed(BaseBookmarksFeed):
title = "Unread bookmarks"
description = "All unread bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
).filter(unread=True)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)
class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks"
description = "All shared bookmarks"
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False
)
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks"
description = "All public shared bookmarks"
def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", ""))
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
return FeedContext(None, query_set)
return super().get_object(request, None)
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -1,4 +1,4 @@
export class ApiClient {
export class Api {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
@@ -27,3 +27,6 @@ export class ApiClient {
.then((data) => data.results);
}
}
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
export const api = new Api(apiBaseUrl);

View File

@@ -5,9 +5,10 @@ class BookmarkItem extends Behavior {
super(element);
// Toggle notes
const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
this.onToggleNotes = this.onToggleNotes.bind(this);
this.notesToggle = element.querySelector(".toggle-notes");
if (this.notesToggle) {
this.notesToggle.addEventListener("click", this.onToggleNotes);
}
// Add tooltip to title if it is truncated
@@ -20,6 +21,12 @@ class BookmarkItem extends Behavior {
});
}
destroy() {
if (this.notesToggle) {
this.notesToggle.removeEventListener("click", this.onToggleNotes);
}
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();

View File

@@ -4,16 +4,22 @@ class BulkEdit extends Behavior {
constructor(element) {
super(element);
this.active = false;
this.active = element.classList.contains("active");
this.init = this.init.bind(this);
this.onToggleActive = this.onToggleActive.bind(this);
this.onToggleAll = this.onToggleAll.bind(this);
this.onToggleBookmark = this.onToggleBookmark.bind(this);
this.onActionSelected = this.onActionSelected.bind(this);
this.init();
// Reset when bookmarks are refreshed
document.addEventListener("refresh-bookmark-list-done", () => this.init());
// Reset when bookmarks are updated
document.addEventListener("bookmark-list-updated", this.init);
}
destroy() {
this.removeListeners();
document.removeEventListener("bookmark-list-updated", this.init);
}
init() {
@@ -31,13 +37,9 @@ class BulkEdit extends Behavior {
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
// Remove previous listeners if elements are the same
this.activeToggle.removeEventListener("click", this.onToggleActive);
this.actionSelect.removeEventListener("change", this.onActionSelected);
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
// Add listeners, ensure there are no dupes by possibly removing existing listeners
this.removeListeners();
this.addListeners();
// Reset checkbox states
this.reset();
@@ -47,8 +49,9 @@ class BulkEdit extends Behavior {
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
// Add new listeners
addListeners() {
this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll);
@@ -57,6 +60,15 @@ class BulkEdit extends Behavior {
});
}
removeListeners() {
this.activeToggle.removeEventListener("click", this.onToggleActive);
this.actionSelect.removeEventListener("change", this.onActionSelected);
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
}
onToggleActive() {
this.active = !this.active;
if (this.active) {

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

@@ -3,17 +3,14 @@ import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior extends Behavior {
constructor(element) {
super(element);
element.dataset.type = element.type;
element.dataset.name = element.name;
element.dataset.value = element.value;
element.removeAttribute("type");
element.removeAttribute("name");
element.removeAttribute("value");
element.addEventListener("click", this.onClick.bind(this));
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
Behavior.interacting = false;
this.reset();
this.element.removeEventListener("click", this.onClick);
}
onClick(event) {
@@ -53,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.dataset.type;
confirmButton.name = this.element.dataset.name;
confirmButton.value = this.element.dataset.value;
confirmButton.type = this.element.type;
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
@@ -70,7 +67,10 @@ class ConfirmButtonBehavior extends Behavior {
reset() {
setTimeout(() => {
Behavior.interacting = false;
this.container.remove();
if (this.container) {
this.container.remove();
this.container = null;
}
this.element.classList.remove("d-none");
});
}

View File

@@ -0,0 +1,62 @@
import { Behavior, registerBehavior } from "./index";
class DetailsModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
this.buttonLink = element.querySelector("a:has(button.close)");
this.overlayLink.addEventListener("click", this.onClose);
this.buttonLink.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.overlayLink.removeEventListener("click", this.onClose);
this.buttonLink.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.element.remove();
const closeUrl = this.overlayLink.href;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
}
},
{ once: true },
);
}
}
registerBehavior("ld-details-modal", DetailsModalBehavior);

View File

@@ -4,33 +4,70 @@ class DropdownBehavior extends Behavior {
constructor(element) {
super(element);
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);
const toggle = element.querySelector(".dropdown-toggle");
toggle.addEventListener("click", () => {
if (this.opened) {
this.close();
} else {
this.open();
}
});
// 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);
}
onClick() {
if (this.opened) {
this.close();
} else {
this.open();
}
}
onOutsideClick(event) {
if (!this.element.contains(event.target)) {
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

@@ -1,48 +0,0 @@
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
class FetchBehavior extends Behavior {
constructor(element) {
super(element);
const eventName = element.getAttribute("ld-on");
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
this.onFetch = this.onFetch.bind(this);
this.onInterval = this.onInterval.bind(this);
element.addEventListener(eventName, this.onFetch);
if (interval) {
this.intervalId = setInterval(this.onInterval, interval);
}
}
destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
async onFetch(maybeEvent) {
if (maybeEvent) {
maybeEvent.preventDefault();
}
const url = this.element.getAttribute("ld-fetch");
const html = await fetch(url).then((response) => response.text());
const target = this.element.getAttribute("ld-target");
const select = this.element.getAttribute("ld-select");
swap(this.element, html, { target, select });
const events = this.element.getAttribute("ld-fire");
fireEvents(events);
}
onInterval() {
if (Behavior.interacting) {
return;
}
this.onFetch();
}
}
registerBehavior("ld-fetch", FetchBehavior);

View File

@@ -1,64 +1,55 @@
import { Behavior, fireEvents, registerBehavior } from "./index";
class FormBehavior extends Behavior {
constructor(element) {
super(element);
element.addEventListener("submit", this.onSubmit.bind(this));
}
async onSubmit(event) {
event.preventDefault();
const url = this.element.action;
const formData = new FormData(this.element);
if (event.submitter) {
formData.append(event.submitter.name, event.submitter.value);
}
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
const events = this.element.getAttribute("ld-fire");
if (fireEvents) {
fireEvents(events);
}
}
}
import { Behavior, registerBehavior } from "./index";
class AutoSubmitBehavior extends Behavior {
constructor(element) {
super(element);
element.addEventListener("change", () => {
const form = element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true }));
});
this.submit = this.submit.bind(this);
element.addEventListener("change", this.submit);
}
destroy() {
this.element.removeEventListener("change", this.submit);
}
submit() {
this.element.closest("form").requestSubmit();
}
}
class UploadButton extends Behavior {
constructor(element) {
super(element);
this.fileInput = element.nextElementSibling;
const fileInput = element.nextElementSibling;
this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this);
element.addEventListener("click", () => {
fileInput.click();
});
element.addEventListener("click", this.onClick);
this.fileInput.addEventListener("change", this.onChange);
}
fileInput.addEventListener("change", () => {
const form = fileInput.closest("form");
const event = new Event("submit", { cancelable: true });
event.submitter = element;
form.dispatchEvent(event);
});
destroy() {
this.element.removeEventListener("click", this.onClick);
this.fileInput.removeEventListener("change", this.onChange);
}
onClick(event) {
event.preventDefault();
this.fileInput.click();
}
onChange() {
// Check if the file input has a file selected
if (!this.fileInput.files.length) {
return;
}
const form = this.fileInput.closest("form");
form.requestSubmit(this.element);
// remove selected file so it doesn't get submitted again
this.fileInput.value = "";
}
}
registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

@@ -4,7 +4,12 @@ class GlobalShortcuts extends Behavior {
constructor(element) {
super(element);
document.addEventListener("keydown", this.onKeyDown.bind(this));
this.onKeyDown = this.onKeyDown.bind(this);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {

View File

@@ -16,9 +16,34 @@ const mutationObserver = new MutationObserver((mutations) => {
});
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
// Update behaviors on Turbo events
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
// - turbo:render: after page navigation, including back/forward, and failed form submissions
// - turbo:before-cache: before page navigation, reset DOM before caching
document.addEventListener(
"turbo:load",
() => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
applyBehaviors(document.body);
},
{ once: true },
);
document.addEventListener("turbo:render", () => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
applyBehaviors(document.body);
});
document.addEventListener("turbo:before-cache", () => {
destroyBehaviors(document.body);
});
export class Behavior {
@@ -33,7 +58,6 @@ Behavior.interacting = false;
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
applyBehaviors(document, [name]);
}
export function applyBehaviors(container, behaviorNames = null) {
@@ -95,51 +119,3 @@ export function destroyBehaviors(element) {
});
});
}
export function swap(element, html, options) {
const dom = new DOMParser().parseFromString(html, "text/html");
let targetElement = element;
let strategy = "innerHTML";
if (options.target) {
const parts = options.target.split("|");
targetElement =
parts[0] === "self" ? element : document.querySelector(parts[0]);
strategy = parts[1] || "innerHTML";
}
let contents = Array.from(dom.body.children);
if (options.select) {
contents = Array.from(dom.querySelectorAll(options.select));
}
switch (strategy) {
case "append":
targetElement.append(...contents);
break;
case "outerHTML":
targetElement.parentElement.replaceChild(contents[0], targetElement);
break;
case "innerHTML":
default:
Array.from(targetElement.children).forEach((child) => {
child.remove();
});
targetElement.append(...contents);
}
}
export function fireEvents(events) {
if (!events) {
return;
}
events.split(",").forEach((eventName) => {
const targets = Array.from(
document.querySelectorAll(`[ld-on='${eventName}']`),
);
targets.push(document);
targets.forEach((target) => {
target.dispatchEvent(new CustomEvent(eventName));
});
});
}

View File

@@ -1,51 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
this.onClose();
}
}
onClose() {
document.removeEventListener("keydown", this.onKeyDown);
this.element.classList.add("closing");
this.element.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") {
this.element.remove();
}
});
}
}
registerBehavior("ld-modal", ModalBehavior);

View File

@@ -0,0 +1,41 @@
import { Behavior, registerBehavior } from "./index";
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
class SearchAutocomplete extends Behavior {
constructor(element) {
super(element);
const input = element.querySelector("input");
if (!input) {
console.warn("SearchAutocomplete: input element not found");
return;
}
const container = document.createElement("div");
new SearchAutoCompleteComponent({
target: container,
props: {
name: "q",
placeholder: input.getAttribute("placeholder") || "",
value: input.value,
linkTarget: input.dataset.linkTarget,
mode: input.dataset.mode,
search: {
user: input.dataset.user,
shared: input.dataset.shared,
unread: input.dataset.unread,
},
},
});
this.input = input;
this.autocomplete = container.firstElementChild;
input.replaceWith(this.autocomplete);
}
destroy() {
this.autocomplete.replaceWith(this.input);
}
}
registerBehavior("ld-search-autocomplete", SearchAutocomplete);

View File

@@ -1,27 +1,35 @@
import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api";
class TagAutocomplete extends Behavior {
constructor(element) {
super(element);
const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl);
const input = element.querySelector("input");
if (!input) {
console.warn("TagAutocomplete: input element not found");
return;
}
const container = document.createElement("div");
new TagAutoCompleteComponent({
target: wrapper,
target: container,
props: {
id: element.id,
name: element.name,
value: element.value,
placeholder: element.getAttribute("placeholder") || "",
apiClient: apiClient,
variant: element.getAttribute("variant"),
id: input.id,
name: input.name,
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
variant: input.getAttribute("variant"),
},
});
element.replaceWith(wrapper.firstElementChild);
this.input = input;
this.autocomplete = container.firstElementChild;
input.replaceWith(this.autocomplete);
}
destroy() {
this.autocomplete.replaceWith(this.input);
}
}

View File

@@ -0,0 +1,68 @@
import { Behavior, registerBehavior } from "./index";
class TagModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header">
<h2>Tags</h2>
<button class="close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(tagCloud);
const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
}
}
registerBehavior("ld-tag-modal", TagModalBehavior);

View File

@@ -0,0 +1,35 @@
import { api } from "./api.js";
class Cache {
constructor(api) {
this.api = api;
// Reset cached tags after a form submission
document.addEventListener("turbo:submit-end", () => {
this.tagsPromise = null;
});
}
getTags() {
if (!this.tagsPromise) {
this.tagsPromise = this.api
.getTags({
limit: 5000,
offset: 0,
})
.then((tags) =>
tags.sort((left, right) =>
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
),
)
.catch((e) => {
console.warn("Cache: Error loading tags", e);
return [];
});
}
return this.tagsPromise;
}
}
export const cache = new Cache(api);

View File

@@ -1,5 +1,7 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
@@ -7,9 +9,7 @@
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let search;
export let linkTarget = '_blank';
@@ -88,17 +88,18 @@
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
.map(tag => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
label: `#${tag.name}`,
tagName: tag.name
}))
}
@@ -119,9 +120,9 @@
...search,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
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,14 +1,13 @@
<script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let placeholder;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
@@ -17,18 +16,6 @@
let suggestions = [];
let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 5000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
@@ -38,9 +25,10 @@
close();
}
function handleInput(e) {
async function handleInput(e) {
input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input);
suggestions = word

View File

@@ -1,12 +1,17 @@
import "@hotwired/turbo";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/clear-button";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/fetch";
import "./behaviors/form";
import "./behaviors/modal";
import "./behaviors/details-modal";
import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { ApiClient } from "./api";
export { api } from "./api";
export { cache } from "./cache";

View File

@@ -1,23 +1,40 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
from bookmarks.models import UserProfile
from bookmarks.models import UserProfile, GlobalSettings
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
class UserProfileMiddleware:
default_global_settings = GlobalSettings()
standard_profile = UserProfile()
standard_profile.enable_favicons = True
class LinkdingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# add global settings to request
try:
global_settings = GlobalSettings.get()
except:
global_settings = default_global_settings
request.global_settings = global_settings
# add user profile to request
if request.user.is_authenticated:
request.user_profile = request.user.profile
else:
request.user_profile = UserProfile()
request.user_profile.enable_favicons = True
# check if a custom profile for guests exists, otherwise use standard profile
if global_settings.guest_profile_user:
request.user_profile = global_settings.guest_profile_user.profile
else:
request.user_profile = standard_profile
response = self.get_response(request)

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.0.8 on 2024-08-31 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0036_userprofile_auto_tagging_rules"),
]
operations = [
migrations.CreateModel(
name="GlobalSettings",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"landing_page",
models.CharField(
choices=[
("login", "Login"),
("shared_bookmarks", "Shared Bookmarks"),
],
default="login",
max_length=50,
),
),
],
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.0.8 on 2024-08-31 17:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0037_globalsettings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="globalsettings",
name="guest_profile_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.8 on 2024-09-14 07:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0038_globalsettings_guest_profile_user"),
]
operations = [
migrations.AddField(
model_name="globalsettings",
name="enable_link_prefetch",
field=models.BooleanField(default=False),
),
]

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,18 +77,17 @@ 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):
return [tag.name for tag in self.tags.all()]
names = [tag.name for tag in self.tags.all()]
return sorted(names)
def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)"
@@ -139,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
@@ -160,8 +158,6 @@ class BookmarkForm(forms.ModelForm):
"title",
"description",
"notes",
"website_title",
"website_description",
"unread",
"shared",
"auto_close",
@@ -169,7 +165,27 @@ class BookmarkForm(forms.ModelForm):
@property
def has_notes(self):
return self.instance and self.instance.notes
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
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:
@@ -279,7 +295,7 @@ class BookmarkSearchForm(forms.Form):
]
q = forms.CharField()
user = forms.ChoiceField()
user = forms.ChoiceField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
@@ -415,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):
@@ -447,6 +477,8 @@ class UserProfileForm(forms.ModelForm):
"default_mark_unread",
"custom_css",
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
]
@@ -492,3 +524,46 @@ class FeedToken(models.Model):
def __str__(self):
return self.key
class GlobalSettings(models.Model):
LANDING_PAGE_LOGIN = "login"
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
LANDING_PAGE_CHOICES = [
(LANDING_PAGE_LOGIN, "Login"),
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
]
landing_page = models.CharField(
max_length=50,
choices=LANDING_PAGE_CHOICES,
blank=False,
default=LANDING_PAGE_LOGIN,
)
guest_profile_user = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
)
enable_link_prefetch = models.BooleanField(default=False, null=False)
@classmethod
def get(cls):
instance = GlobalSettings.objects.first()
if not instance:
instance = GlobalSettings()
instance.save()
return instance
def save(self, *args, **kwargs):
if not self.pk and GlobalSettings.objects.exists():
raise Exception("There is already one instance of GlobalSettings")
return super(GlobalSettings, self).save(*args, **kwargs)
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
def __init__(self, *args, **kwargs):
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

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("#")
@@ -16,27 +19,21 @@ def get_tags(script: str, url: str):
if len(parts) < 2:
continue
domain_pattern = re.sub("^https?://", "", parts[0])
path_pattern = None
qs_pattern = None
# to parse a host name from the pattern URL, ensure it has a scheme
pattern_url = "//" + re.sub("^https?://", "", parts[0])
parsed_pattern = urlparse(pattern_url)
if "/" in domain_pattern:
i = domain_pattern.index("/")
path_pattern = domain_pattern[i:]
domain_pattern = domain_pattern[:i]
if path_pattern and "?" in path_pattern:
i = path_pattern.index("?")
qs_pattern = path_pattern[i + 1 :]
path_pattern = path_pattern[:i]
if not _domains_matches(domain_pattern, parsed_url.netloc):
if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):
continue
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
if parsed_pattern.path and not _path_matches(
parsed_pattern.path, parsed_url.path
):
continue
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
if parsed_pattern.query and not _qs_matches(
parsed_pattern.query, parsed_url.query
):
continue
for tag in parts[1:]:

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,22 +241,22 @@ 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)
if user.profile.auto_tagging_rules:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
try:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
exc_info=e,
)
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)

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

@@ -79,8 +79,6 @@ def import_netscape_html(
for batch in batches:
_import_batch(batch, user, options, tag_cache, result)
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user)
# Load previews for newly imported bookmarks
@@ -233,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

@@ -9,7 +9,7 @@ import subprocess
from django.conf import settings
class SingeFileError(Exception):
class SingleFileError(Exception):
pass
@@ -31,7 +31,7 @@ def create_snapshot(url: str, filepath: str):
# check if the file was created
if not os.path.exists(temp_filepath):
raise SingeFileError("Failed to create snapshot")
raise SingleFileError("Failed to create snapshot")
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
@@ -47,12 +47,12 @@ def create_snapshot(url: str, filepath: str):
)
process.terminate()
process.wait(timeout=20)
raise SingeFileError("Timeout expired while creating snapshot")
raise SingleFileError("Timeout expired while creating snapshot")
except subprocess.TimeoutExpired:
# Kill the whole process group, which should also clean up any chromium
# processes spawned by single-file
logger.error("Timeout expired while terminating. Killing process...")
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
raise SingeFileError("Timeout expired while creating snapshot")
raise SingleFileError("Timeout expired while creating snapshot")
except subprocess.CalledProcessError as error:
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")
raise SingleFileError(f"Failed to create snapshot: {error.stderr}")

View File

@@ -12,9 +12,8 @@ from django.utils import timezone, formats
from huey import crontab
from huey.contrib.djhuey import HUEY as huey
from huey.exceptions import TaskLockedException
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
from waybackpy.exceptions import WaybackError, TooManyRequestsError
import bookmarks.services.wayback
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
@@ -66,29 +65,6 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
_create_web_archive_snapshot_task(bookmark.id, force_update)
def _load_newest_snapshot(bookmark: Bookmark):
try:
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
bookmark.url
)
existing_snapshot = cdx_api.newest()
if existing_snapshot:
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
bookmark.save(update_fields=["web_archive_snapshot_url"])
logger.info(
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
)
except NoCDXRecordFound:
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
except WaybackError as error:
logger.error(
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
)
def _create_snapshot(bookmark: Bookmark):
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
archive = waybackpy.WaybackMachineSaveAPI(
@@ -117,48 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
return
except TooManyRequestsError:
logger.error(
f"Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}"
f"Failed to create snapshot due to rate limiting. url={bookmark.url}"
)
except WaybackError as error:
logger.error(
f"Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}",
f"Failed to create snapshot. url={bookmark.url}",
exc_info=error,
)
# Load the newest snapshot as fallback
_load_newest_snapshot(bookmark)
@task()
def _load_web_archive_snapshot_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
# Skip if snapshot exists
if bookmark.web_archive_snapshot_url:
return
# Load the newest snapshot
_load_newest_snapshot(bookmark)
def schedule_bookmarks_without_snapshots(user: User):
if is_web_archive_integration_active(user):
_schedule_bookmarks_without_snapshots_task(user.id)
# Loading snapshots from CDX API has been removed, keeping the task function
# for now to prevent errors when huey tries to run the task
pass
@task()
def _schedule_bookmarks_without_snapshots_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks_without_snapshots = Bookmark.objects.filter(
web_archive_snapshot_url__exact="", owner=user
)
# TODO: Implement bulk task creation
for bookmark in bookmarks_without_snapshots:
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
# new ones when processing bookmarks in bulk
_load_web_archive_snapshot_task(bookmark.id)
# Loading snapshots from CDX API has been removed, keeping the task function
# for now to prevent errors when huey tries to run the task
pass
def is_favicon_feature_active(user: User) -> bool:

View File

@@ -1,42 +1,20 @@
import time
from typing import Dict
import datetime
import waybackpy
import waybackpy.utils
from waybackpy.exceptions import NoCDXRecordFound
from django.utils import timezone
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
def generate_fallback_webarchive_url(
url: str, timestamp: datetime.datetime
) -> str | None:
"""
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
See https://github.com/akamhy/waybackpy/issues/176
Generate a URL to the web archive for the given URL and timestamp.
A snapshot for the specific timestamp might not exist, in which case the
web archive will show the closest snapshot to the given timestamp.
If there is no snapshot at all the URL will be invalid.
"""
if not url:
return None
if not timestamp:
timestamp = timezone.now()
def newest(self):
unix_timestamp = int(time.time())
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
unix_timestamp
)
self.sort = "closest"
self.limit = -5
newest_snapshot = None
for snapshot in self.snapshots():
newest_snapshot = snapshot
break
if not newest_snapshot:
raise NoCDXRecordFound(
"Wayback Machine's CDX server did not return any records "
+ "for the query. The URL may not have any archives "
+ " on the Wayback Machine or the URL may have been recently "
+ "archived and is still not available on the CDX server."
)
return newest_snapshot
def add_payload(self, payload: Dict[str, str]) -> None:
super().add_payload(payload)
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
# makes searching for latest snapshots faster
payload["fastLatest"] = "true"
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"

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

@@ -1,15 +1,7 @@
from django.conf import settings
from django.contrib.auth import user_logged_in
from django.db.backends.signals import connection_created
from django.dispatch import receiver
from bookmarks.services import tasks
@receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs):
tasks.schedule_bookmarks_without_snapshots(user)
@receiver(connection_created)
def extend_sqlite(connection=None, **kwargs):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

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

@@ -1,136 +0,0 @@
/* Main layout */
body {
margin: 20px 10px;
@media (min-width: $size-sm) {
// Horizontal padding accounts for checkboxes that show up in bulk edit mode
margin: 20px 32px;
}
}
header {
margin-bottom: $unit-9;
.logo {
width: 28px;
height: 28px;
}
a:hover {
text-decoration: none;
}
h1 {
margin: 0 0 0 $unit-3;
font-size: $font-size-lg;
}
}
header .toasts {
margin-bottom: 20px;
.toast {
margin-bottom: 0.4rem;
}
.toast a.btn-clear:visited {
color: currentColor;
}
}
/* Shared components */
// Content area component
section.content-area {
h2 {
font-size: $font-size-lg;
}
.content-area-header {
border-bottom: solid 1px $border-color;
display: flex;
flex-wrap: wrap;
column-gap: $unit-5;
padding-bottom: $unit-1;
margin-bottom: $unit-3;
h2 {
flex: 0 0 auto;
line-height: $unit-9;
margin: 0;
}
.header-controls {
flex: 1 1 0;
display: flex;
}
}
}
// Confirm button component
span.confirmation {
display: flex;
align-items: baseline;
gap: $unit-1;
color: $error-color !important;
svg {
align-self: center;
}
.btn.btn-link {
color: $error-color !important;
&:hover {
text-decoration: underline;
}
}
}
/* Additional utilities */
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text-gray-dark {
color: $gray-color-dark;
}
.align-baseline {
align-items: baseline;
}
.align-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.mb-4 {
margin-bottom: $unit-4;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.btn.btn-wide {
padding-left: $unit-6;
padding-right: $unit-6;
}
.btn.btn-sm.btn-icon {
display: inline-flex;
align-items: baseline;
gap: $unit-h;
svg {
align-self: center;
}
}

View File

@@ -0,0 +1,131 @@
/* Common styles */
.bookmark-details {
& .weblinks {
display: flex;
flex-direction: column;
gap: var(--unit-2);
}
& a.weblink {
display: flex;
align-items: center;
gap: var(--unit-2);
}
& a.weblink img,
& a.weblink svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: var(--text-color);
}
& a.weblink span {
flex: 1 1 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& .preview-image {
margin: var(--unit-4) 0;
img {
max-width: 100%;
max-height: 200px;
}
}
& dl {
margin-bottom: 0;
}
& .assets {
margin-top: var(--unit-2);
& .asset {
display: flex;
align-items: center;
gap: var(--unit-2);
padding: var(--unit-2) 0;
border-top: var(--unit-o) solid var(--secondary-border-color);
}
& .asset:last-child {
border-bottom: var(--unit-o) solid var(--secondary-border-color);
}
& .asset-icon {
display: flex;
align-items: center;
justify-content: center;
}
& .asset-text {
flex: 1 1 0;
gap: var(--unit-2);
min-width: 0;
display: flex;
}
& .asset-text .truncate {
flex-shrink: 1;
}
& .asset-text .filesize {
color: var(--tertiary-text-color);
}
& .asset-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
}
& .assets-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
margin-top: var(--unit-2);
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
& .tags a {
color: var(--alternative-color);
}
& .status form {
display: flex;
gap: var(--unit-2);
}
& .status .form-group,
.status .form-switch {
margin: 0;
}
& .actions {
display: flex;
justify-content: space-between;
align-items: center;
}
}
/* Bookmark details view specific */
.bookmark-details.page {
display: flex;
flex-direction: column;
gap: var(--unit-6);
}

View File

@@ -1,141 +0,0 @@
/* Common styles */
.bookmark-details {
h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
.weblinks {
display: flex;
flex-direction: column;
gap: $unit-2;
}
a.weblink {
display: flex;
align-items: center;
gap: $unit-2;
}
a.weblink img, a.weblink svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: $body-font-color;
}
a.weblink span {
flex: 1 1 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-image {
margin: $unit-4 0;
img {
max-width: 100%;
max-height: 200px;
}
}
dl {
margin-bottom: 0;
}
.assets {
margin-top: $unit-2;
}
.assets .asset {
display: flex;
align-items: center;
gap: $unit-3;
padding: $unit-2 0;
border-top: $unit-o solid $border-color-light;
}
.assets .asset:last-child {
border-bottom: $unit-o solid $border-color-light;
}
.assets .asset-icon {
display: flex;
align-items: center;
justify-content: center;
}
.assets .asset-text {
flex: 1 1 0;
gap: $unit-2;
min-width: 0;
display: flex;
}
.assets .asset-text .truncate {
flex-shrink: 1;
}
.assets .asset-text .filesize {
color: $gray-color;
}
.assets .asset-actions, .assets-actions {
display: flex;
gap: $unit-4;
align-items: center;
}
.assets .asset-actions .btn, .assets-actions .btn {
height: unset;
padding: 0;
border: none;
}
.assets-actions {
margin-top: $unit-2;
}
.tags a {
color: $alternative-color;
}
.status form {
display: flex;
gap: $unit-2;
}
.status .form-group, .status .form-switch {
margin: 0;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
}
/* Bookmark details view specific */
.bookmark-details.page {
display: flex;
flex-direction: column;
gap: $unit-6;
}
/* Bookmark details modal specific */
.bookmark-details.modal {
.modal-header {
display: flex;
align-items: flex-start;
gap: $unit-2;
}
.modal-body {
padding-top: 0;
padding-bottom: 0;
}
}

View File

@@ -0,0 +1,39 @@
.bookmarks-form-page {
section {
max-width: 550px;
margin: 0 auto;
}
}
.bookmarks-form {
& .has-icon-right > input,
& .has-icon-right > textarea {
padding-right: 30px;
}
& .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);
}
& .form-input-hint.auto-tags {
display: none;
color: var(--success-color);
}
& details.notes textarea {
box-sizing: border-box;
}
}

View File

@@ -1,49 +0,0 @@
.bookmarks-form {
.btn.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
color: $gray-color;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
> svg {
width: 20px;
height: 20px;
}
}
.has-icon-right > input, .has-icon-right > textarea {
padding-right: 30px;
}
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
.form-icon.loading {
visibility: hidden;
}
.form-input-hint.bookmark-exists {
display: none;
color: $warning-color;
}
.form-input-hint.auto-tags {
display: none;
color: $success-color;
}
details.notes textarea {
box-sizing: border-box;
}
}

View File

@@ -0,0 +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 page grid */
.bookmarks-page.grid {
grid-gap: var(--unit-9);
}
/* Bookmark area header controls */
.bookmarks-page .search-container {
flex: 1 1 0;
display: flex;
max-width: 300px;
margin-left: auto;
& form {
width: 100%;
}
@media (max-width: 600px) {
max-width: initial;
margin-left: 0;
}
/* Regular input */
& input[type="search"] {
height: var(--control-size);
-webkit-appearance: none;
}
/* Enhanced auto-complete input */
/* This needs a bit more wrangling to make the CSS component align with the attached button */
& .form-autocomplete {
height: var(--control-size);
& .form-autocomplete-input {
width: 100%;
height: var(--control-size);
& input[type="search"] {
width: 100%;
height: 100%;
margin: 0;
border: none;
}
}
}
/* Group search options button with search button */
height: var(--control-size);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow-xs);
& input,
& .form-autocomplete-input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
& .dropdown-toggle {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
outline-offset: calc(var(--focus-outline-offset) * -1);
}
/* Search option menu styles */
& .dropdown {
& .menu {
padding: var(--unit-4);
min-width: 250px;
font-size: var(--font-size-sm);
}
& .menu .actions {
margin-top: var(--unit-4);
display: flex;
justify-content: space-between;
}
& .form-group:first-of-type {
margin-top: 0;
}
& .form-group {
margin-bottom: var(--unit-3);
}
& .radio-group {
& .form-label {
margin-bottom: var(--unit-1);
}
& .form-radio.form-inline {
margin: 0 var(--unit-2) 0 0;
padding: 0;
display: inline-flex;
align-items: center;
column-gap: var(--unit-1);
}
& .form-icon {
top: 0;
position: relative;
}
}
}
}
/* Bookmark list */
ul.bookmark-list {
list-style: none;
margin: 0;
padding: 0;
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
}
@keyframes appear {
0% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Bookmarks */
li[ld-bookmark-item] {
position: relative;
display: flex;
gap: var(--unit-2);
margin-top: 0;
margin-bottom: var(--unit-3);
& .content {
flex: 1 1 0;
min-width: 0;
}
& .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;
align-items: baseline;
flex-wrap: wrap;
column-gap: var(--unit-2);
}
@media (max-width: 600px) {
& .extra-actions {
width: 100%;
margin-top: var(--unit-1);
}
}
& .actions {
color: var(--bookmark-actions-color);
font-size: var(--font-size-sm);
& a,
& button.btn-link {
color: var(--bookmark-actions-color);
--btn-icon-color: var(--bookmark-actions-color);
font-weight: var(--bookmark-actions-weight);
padding: 0;
height: auto;
vertical-align: unset;
border: none;
box-sizing: border-box;
transition: none;
text-decoration: none;
&:focus,
&:hover,
&:active,
&.active {
color: var(--bookmark-actions-hover-color);
--btn-icon-color: var(--bookmark-actions-hover-color);
}
}
}
}
.bookmark-pagination {
margin-top: var(--unit-4);
/* Remove left padding from first pagination link */
& .page-item:first-child a {
padding-left: 0;
}
&.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;
& .selected-tags {
margin-bottom: var(--unit-4);
& a,
& a:visited:hover {
color: var(--error-color);
}
}
& .unselected-tags {
& a,
& a:visited:hover {
color: var(--alternative-color);
}
}
& .group {
margin-bottom: var(--unit-3);
}
& .highlight-char {
font-weight: bold;
text-transform: uppercase;
color: var(--alternative-color-dark);
}
}
/* Bookmark notes */
ul.bookmark-list {
& .notes {
display: none;
max-height: 300px;
margin: var(--unit-1) 0;
overflow-y: auto;
background: var(--body-color-contrast);
border-radius: var(--border-radius);
}
& .notes .markdown {
padding: var(--unit-2) var(--unit-3);
}
&.show-notes .notes,
& li.show-notes .notes {
display: block;
}
}
/* Bookmark bulk edit */
:root {
--bulk-edit-toggle-width: 16px;
--bulk-edit-toggle-offset: 8px;
--bulk-edit-bar-offset: calc(
var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset))
);
--bulk-edit-transition-duration: 400ms;
}
[ld-bulk-edit] {
& .bulk-edit-bar {
margin-top: -1px;
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
margin-bottom: var(--unit-4);
max-height: 0;
overflow: hidden;
transition: max-height var(--bulk-edit-transition-duration);
background: var(--bulk-actions-bg-color);
}
&.active .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px var(--secondary-border-color);
}
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
&.active section:first-of-type .content-area-header {
border-bottom-color: transparent;
}
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
&.active:not(.activating) .bulk-edit-bar {
overflow: visible;
}
/* 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;
}
& 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);
}
}
}

View File

@@ -1,408 +0,0 @@
.bookmarks-page.grid {
grid-gap: $unit-9;
}
/* Bookmark area header controls */
.bookmarks-page .content-area-header {
--searchbox-max-width: 350px;
@media (max-width: $size-sm) {
--searchbox-max-width: initial;
flex-direction: column;
}
}
.bookmarks-page .search-container {
flex: 1 1 0;
display: flex;
justify-content: flex-end;
// Regular input
input[type='search'] {
height: $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: $control-size;
.form-autocomplete-input {
width: 100%;
height: $control-size;
input[type='search'] {
width: 100%;
height: 100%;
margin: 0;
border: none;
}
}
}
.input-group {
flex: 1 1 0;
min-width: var(--searchbox-min-width);
max-width: var(--searchbox-max-width);
}
.input-group > :first-child {
flex: 1 1 0;
}
// Group search options button with search button
.input-group input[type='submit'] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dropdown-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dropdown {
margin-left: -1px;
}
// Search option menu styles
.dropdown {
.menu {
padding: $unit-4;
min-width: 250px;
font-size: $font-size-sm;
}
.menu .actions {
margin-top: $unit-4;
display: flex;
justify-content: space-between;
}
.radio-group {
margin-bottom: $unit-1;
.form-label {
padding-bottom: 0;
}
.form-radio.form-inline {
margin: 0 $unit-2 0 0;
padding: 0;
display: inline-flex;
align-items: center;
column-gap: $unit-1;
}
.form-icon {
top: 0;
position: relative;
}
}
}
}
/* Bookmark list */
ul.bookmark-list {
list-style: none;
margin: 0;
padding: 0;
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
}
@keyframes appear {
0% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Bookmarks */
li[ld-bookmark-item] {
position: relative;
display: flex;
gap: $unit-2;
margin-top: $unit-2;
.content {
flex: 1 1 0;
min-width: 0;
}
img.preview-image {
flex: 0 0 auto;
width: 100px;
height: 60px;
margin-top: $unit-h;
object-fit: cover;
border-radius: $border-radius;
border: solid 1px $border-color-dark;
}
.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 {
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: $unit-1;
border-radius: $border-radius;
border: 1px solid #424a8c;
font-size: $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: $font-size-sm;
color: $secondary-link-color;
}
.description {
color: $gray-color-dark;
}
.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: $alternative-color;
}
}
.actions, .extra-actions {
display: flex;
align-items: baseline;
flex-wrap: wrap;
column-gap: $unit-2;
}
@media (max-width: $size-sm) {
.extra-actions {
width: 100%;
margin-top: $unit-1;
}
}
.actions {
font-size: $font-size-sm;
a, button.btn-link {
color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
transition: none;
text-decoration: none;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
}
}
}
.bookmark-pagination {
margin-top: $unit-4;
}
.tag-cloud {
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
.selected-tags {
margin-bottom: $unit-4;
a, a:visited:hover {
color: $error-color;
}
}
.unselected-tags {
a, a:visited:hover {
color: $alternative-color;
}
}
.group {
margin-bottom: $unit-2;
}
.highlight-char {
font-weight: bold;
text-transform: uppercase;
color: $alternative-color-dark;
}
}
/* Bookmark notes */
ul.bookmark-list {
.notes {
display: none;
max-height: 300px;
margin: $unit-1 0;
overflow-y: auto;
}
.notes .markdown {
padding: $unit-2 $unit-3;
}
&.show-notes .notes,
li.show-notes .notes {
display: block;
}
}
/* Bookmark bulk edit */
$bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms;
[ld-bulk-edit] {
.bulk-edit-bar {
margin-top: -1px;
margin-left: -$bulk-edit-bar-offset;
margin-bottom: $unit-3;
max-height: 0;
overflow: hidden;
transition: max-height $bulk-edit-transition-duration;
}
&.active .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px $border-color;
}
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
&.active:not(.activating) .bulk-edit-bar {
overflow: visible;
}
/* All checkbox */
.form-checkbox.bulk-edit-checkbox.all {
display: block;
width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0;
}
/* Bookmark checkboxes */
li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
display: block;
position: absolute;
width: $bulk-edit-toggle-width;
min-height: $bulk-edit-toggle-width;
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
top: 50%;
transform: translateY(-50%);
padding: 0;
margin: 0;
visibility: hidden;
opacity: 0;
transition: all $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: $unit-1 0;
border-top: solid 1px $border-color;
gap: $unit-2;
button {
padding: 0 !important;
}
button:hover {
text-decoration: underline;
}
> input, .form-autocomplete, select {
width: auto;
max-width: 140px;
-webkit-appearance: none;
}
.select-across {
margin: 0 0 0 auto;
font-size: $font-size-sm;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,3 @@
.container {
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: $size-lg;
}
.show-sm,
.show-md {
display: none !important;
@@ -26,11 +19,18 @@
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: var(--size-lg);
}
.grid {
--grid-columns: 3;
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
grid-gap: $unit-4;
grid-gap: var(--unit-4);
}
.grid > * {
@@ -46,18 +46,18 @@
}
.col-1 {
grid-column: unquote("span min(1, var(--grid-columns))");
grid-column: span min(1, var(--grid-columns));
}
.col-2 {
grid-column: unquote("span min(2, var(--grid-columns))");
grid-column: span min(2, var(--grid-columns));
}
.col-3 {
grid-column: unquote("span min(3, var(--grid-columns))");
grid-column: span min(3, var(--grid-columns));
}
@media (max-width: $size-md) {
@media (max-width: 840px) {
.hide-md {
display: none !important;
}
@@ -86,7 +86,7 @@
}
}
@media (max-width: $size-sm) {
@media (max-width: 600px) {
.hide-sm {
display: none !important;
}

View File

@@ -0,0 +1,26 @@
.settings-page {
section.content-area {
margin-bottom: var(--unit-10);
h2 {
margin-bottom: var(--unit-3);
}
}
textarea.monospace {
font-family: monospace;
box-sizing: border-box;
}
.input-group > input[type="submit"] {
height: auto;
}
section.about table {
max-width: 400px;
}
& .form-group {
margin-bottom: var(--unit-4);
}
}

View File

@@ -1,21 +0,0 @@
.settings-page {
section.content-area {
margin-bottom: $unit-10;
h2 {
margin-bottom: $unit-3;
}
}
textarea.custom-css {
font-family: monospace;
}
.input-group > input[type=submit] {
height: auto;
}
section.about table {
max-width: 500px;
}
}

View File

@@ -1,197 +0,0 @@
// Customized Spectre CSS imports, removing modules that are not used
// See node_modules/spectre.css/src/spectre.scss for the original version
// Variables and mixins
@import "../../node_modules/spectre.css/src/variables";
// Customize variables to reduce font and control sizes
// Can use CSS variables for font sizes, as they are not used in SCSS calculations
$font-size: var(--font-size);
$font-size-sm: var(--font-size-sm);
$font-size-lg: var(--font-size-lg);
// Can't use CSS variables for these, used in SCSS calculations
$line-height: 1rem;
$control-size: $unit-8;
$control-size-sm: $unit-6;
$control-size-lg: $unit-9;
// Declare defaults for CSS variables, expose SCSS variables as CSS variables
html {
--font-size: 0.7rem;
--font-size-sm: 0.65rem;
--font-size-lg: 0.8rem;
--control-size: #{$control-size};
--control-size-sm: #{$control-size-sm};
--control-size-lg: #{$control-size-lg};
}
// Mixins
@import "../../node_modules/spectre.css/src/mixins";
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
// Reset and dependencies
@import "../../node_modules/spectre.css/src/normalize";
@import "../../node_modules/spectre.css/src/base";
// Elements
@import "../../node_modules/spectre.css/src/typography";
@import "../../node_modules/spectre.css/src/asian";
@import "../../node_modules/spectre.css/src/tables";
@import "../../node_modules/spectre.css/src/buttons";
@import "../../node_modules/spectre.css/src/forms";
@import "../../node_modules/spectre.css/src/labels";
@import "../../node_modules/spectre.css/src/codes";
@import "../../node_modules/spectre.css/src/media";
// Components
@import "../../node_modules/spectre.css/src/badges";
@import "../../node_modules/spectre.css/src/dropdowns";
@import "../../node_modules/spectre.css/src/empty";
@import "../../node_modules/spectre.css/src/menus";
@import "../../node_modules/spectre.css/src/modals";
@import "../../node_modules/spectre.css/src/pagination";
@import "../../node_modules/spectre.css/src/tabs";
@import "../../node_modules/spectre.css/src/toasts";
@import "../../node_modules/spectre.css/src/tooltips";
// Utility classes
@import "../../node_modules/spectre.css/src/animations";
@import "../../node_modules/spectre.css/src/utilities";
// Auto-complete component
@import "../../node_modules/spectre.css/src/autocomplete";
/* Spectre overrides / fixes */
// Fix up visited styles
a:visited {
color: $link-color;
}
a:visited:hover {
color: $link-color-dark;
}
.btn-link:visited:not(.btn-primary) {
color: $link-color;
}
.btn-link:visited:not(.btn-primary):hover {
color: $link-color-dark;
}
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
.btn {
transition: none !important;
}
// Make code work with light and dark theme
code {
color: $gray-color-dark;
background-color: $code-bg-color;
box-shadow: 1px 1px 0 $code-shadow-color;
}
// Remove left padding from first pagination link
.pagination .page-item:first-child a {
padding-left: 0;
}
// Override border color for tab block
.tab-block {
border-bottom: solid 1px $border-color;
}
// Fix padding for first menu item
ul.menu li:first-child {
margin-top: 0;
}
// Form auto-complete menu
.form-autocomplete .menu {
.menu-item.selected > a, .menu-item > a:hover {
background: $secondary-color;
color: $primary-color;
}
.group-item, .group-item:hover {
color: $gray-color;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}
.modal {
// Add border to separate from background in dark mode
.modal-container {
border: solid 1px $border-color;
}
// Fix modal header to use default color
.modal-header {
color: inherit;
}
}
// Customize modal animation
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.modal.active .modal-container, .modal.active .modal-overlay {
animation: fade-in .15s ease 1;
}
.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
animation: fade-out .15s ease 1;
}
// Customize menu animation
.dropdown .menu {
animation: fade-in .15s ease 1;
}
// Modal close button
.modal .modal-header button.close {
background: none;
border: none;
padding: 0;
line-height: 0;
cursor: pointer;
opacity: .85;
color: $gray-color-dark;
&:hover {
opacity: 1;
}
}
// 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;
}
}

View File

@@ -0,0 +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);
--primary-color: hsl(241, 75%, 64%);
--primary-color-highlight: hsl(241, 75%, 68%);
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
--alternative-color: hsl(179, 50%, 58%);
--alternative-color-dark: hsl(179, 80%, 75%);
--success-color: hsl(142, 76%, 36%);
--success-color-highlight: hsl(142, 76%, 40%);
--success-color-shade: hsla(142, 76%, 36%, 0.1);
--warning-color: hsl(38, 92%, 50%);
--warning-color-highlight: hsl(38, 92%, 55%);
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
--error-color: hsl(0, 80%, 60%);
--error-color-highlight: hsl(0, 72%, 60%);
--error-color-shade: hsla(0, 72%, 51%, 0.1);
/* Core colors */
--text-color: var(--gray-300);
--secondary-text-color: var(--gray-400);
--tertiary-text-color: var(--gray-500);
--contrast-text-color: #fff;
--primary-text-color: hsl(241, 82%, 82%);
--link-color: var(--primary-text-color);
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
--icon-color: var(--text-color);
--border-color: var(--contrast-30);
--secondary-border-color: var(--contrast-20);
--body-color: hsl(241, 15%, 14%);
--body-color-contrast: var(--contrast-10);
/* Focus */
--focus-outline: 2px solid hsl(241, 100%, 78%);
--focus-outline-offset: 2px;
/* Shadows */
--box-shadow-xs: none;
--box-shadow: none;
--box-shadow-lg: none;
}
:root {
--input-bg-color: var(--contrast-5);
--input-disabled-bg-color: var(--contrast-30);
--input-text-color: var(--text-color);
--input-hint-color: var(--secondary-text-color);
--input-border-color: var(--border-color);
--input-placeholder-color: var(--tertiary-text-color);
--input-box-shadow: var(--box-shadow-xs);
--checkbox-bg-color: var(--contrast-10);
--checkbox-checked-bg-color: var(--primary-color);
--checkbox-disabled-bg-color: var(--contrast-30);
--checkbox-border-color: var(--border-color);
--checkbox-icon-color: #fff;
--switch-bg-color: var(--contrast-10);
--switch-border-color: var(--border-color);
--switch-toggle-color: var(--text-color);
}
:root {
--btn-bg-color: var(--contrast-5);
--btn-hover-bg-color: var(--contrast-20);
--btn-border-color: var(--border-color);
--btn-text-color: var(--text-color);
--btn-icon-color: var(--icon-color);
--btn-font-weight: 400;
--btn-box-shadow: var(--box-shadow-xs);
--btn-primary-bg-color: var(--primary-color);
--btn-primary-hover-bg-color: var(--primary-color-highlight);
--btn-primary-text-color: var(--contrast-text-color);
--btn-success-bg-color: var(--success-color);
--btn-success-hover-bg-color: var(--success-color-highlight);
--btn-success-text-color: var(--contrast-text-color);
--btn-error-bg-color: var(--error-color);
--btn-error-hover-bg-color: var(--error-color-highlight);
--btn-error-text-color: var(--contrast-text-color);
--btn-link-text-color: var(--link-color);
--btn-link-hover-text-color: var(--link-color);
}
:root {
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
--modal-container-bg-color: hsl(241, 20%, 20%);
--modal-container-border-color: var(--contrast-30);
--modal-border-radius: var(--border-radius-lg);
--modal-box-shadow: none;
}
:root {
--menu-bg-color: hsl(241, 20%, 20%);
--menu-border-color: var(--contrast-30);
--menu-border-radius: var(--border-radius);
--menu-box-shadow: none;
--menu-item-color: var(--text-color);
--menu-item-hover-color: var(--text-color);
--menu-item-bg-color: transparent;
--menu-item-hover-bg-color: var(--contrast-20);
}
:root {
--tab-color: var(--text-color);
--tab-hover-color: var(--primary-text-color);
--tab-active-color: var(--primary-text-color);
--tab-highlight-color: var(--primary-text-color);
}
:root {
--bookmark-title-color: var(--primary-text-color);
--bookmark-title-weight: 500;
--bookmark-description-color: var(--text-color);
--bookmark-description-weight: 400;
--bookmark-actions-color: var(--secondary-text-color);
--bookmark-actions-hover-color: var(--text-color);
--bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--contrast-5);
}
/* 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

@@ -1,66 +0,0 @@
// Import custom variables
@import "variables-dark";
// Import Spectre CSS lib
@import "spectre";
// Import style modules
@import "base";
@import "responsive";
@import "bookmark-details";
@import "bookmark-page";
@import "bookmark-form";
@import "settings";
@import "markdown";
@import "reader-mode";
/* Dark theme overrides */
// Buttons
.btn.btn-primary {
background: $dt-primary-button-color;
border-color: darken($dt-primary-button-color, 5%);
&:hover, &:active, &:focus {
background: darken($dt-primary-button-color, 5%);
border-color: darken($dt-primary-button-color, 10%);
}
}
// Focus ring
a:focus, .btn:focus {
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
}
// Forms
.form-input:not(:placeholder-shown):invalid,
.form-input:not(:placeholder-shown):invalid:focus,
.has-error .form-input,
.form-input.is-error,
.has-error .form-select,
.form-select.is-error {
background: darken($error-color, 40%);
}
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
background: $dt-primary-input-color;
border-color: $dt-primary-input-color;
}
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
background: $light-color;
}
.form-switch input:checked + .form-icon {
background: $dt-primary-input-color;
border-color: $dt-primary-input-color;
}
.form-radio input:checked + .form-icon::before {
background: $light-color;
}
// Pagination
.pagination .page-item.active a {
background: $dt-primary-button-color;
}

View File

@@ -0,0 +1,30 @@
@import "theme/variables.css";
@import "theme/_normalize.css";
@import "theme/base.css";
@import "theme/typography.css";
@import "theme/asian.css";
@import "theme/tables.css";
@import "theme/buttons.css";
@import "theme/forms.css";
@import "theme/code.css";
@import "theme/dropdowns.css";
@import "theme/menus.css";
@import "theme/badges.css";
@import "theme/empty.css";
@import "theme/modals.css";
@import "theme/pagination.css";
@import "theme/tabs.css";
@import "theme/toasts.css";
@import "theme/autocomplete.css";
@import "theme/animations.css";
@import "theme/utilities.css";
@import "responsive.css";
@import "layout.css";
@import "components.css";
@import "bookmark-details.css";
@import "bookmark-form.css";
@import "bookmark-page.css";
@import "markdown.css";
@import "reader-mode.css";
@import "settings.css";

View File

@@ -1,15 +0,0 @@
// Import custom variables
@import "variables-light";
// Import Spectre CSS lib
@import "spectre";
// Import style modules
@import "base";
@import "responsive";
@import "bookmark-details";
@import "bookmark-page";
@import "bookmark-form";
@import "settings";
@import "markdown";
@import "reader-mode";

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 - 2020 Yan Zhu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,448 @@
/* Manually forked from Normalize.css */
/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
/**
* 1. Change the default font family in all browsers (opinionated).
* 2. Correct the line height in all browsers.
* 3. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
*/
/* Document
========================================================================== */
html {
font-family: sans-serif; /* 1 */
-ms-text-size-adjust: 100%; /* 3 */
-webkit-text-size-adjust: 100%; /* 3 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main {
/* 1 */
display: block;
}
/**
* Add the correct margin in IE 8 (removed).
*/
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers. (removed)
* 2. Correct the odd `em` font sizing in all browsers.
*/
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* Remove the outline on focused links when they are also active or hovered
* in all browsers (opinionated).
*/
a:active,
a:hover {
outline-width: 0;
}
/**
* Modify default styling of address.
*/
address {
font-style: normal;
}
/**
* 1. Remove the bottom border in Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed)
*/
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
pre,
samp {
font-family: var(--mono-font-family); /* 1 (changed) */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-. (Removed)
*/
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
font-weight: 400; /* (added) */
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 (changed) */
font-size: inherit; /* 1 (changed) */
line-height: inherit; /* 1 (changed) */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule (removed).
*/
/**
* Change the border, margin, and padding in all browsers (opinionated) (changed).
*/
fieldset {
border: 0;
margin: 0;
padding: 0;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details, /* 1 */
menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
outline: none;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* Hidden
========================================================================== */
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}

View File

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

View File

@@ -0,0 +1,43 @@
/* Optimized for East Asian CJK */
html:lang(zh),
html:lang(zh-Hans),
.lang-zh,
.lang-zh-hans {
font-family: var(--cjk-zh-hans-font-family);
}
html:lang(zh-Hant),
.lang-zh-hant {
font-family: var(--cjk-zh-hant-font-family);
}
html:lang(ja),
.lang-ja {
font-family: var(--cjk-jp-font-family);
}
html:lang(ko),
.lang-ko {
font-family: var(--cjk-ko-font-family);
}
:lang(zh),
:lang(ja),
.lang-cjk {
& ins,
& u {
border-bottom: var(--border-width) solid;
text-decoration: none;
}
& del + del,
& del + s,
& ins + ins,
& ins + u,
& s + del,
& s + s,
& u + ins,
& u + u {
margin-left: 0.125em;
}
}

View File

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

View File

@@ -0,0 +1,64 @@
/* Badges */
.badge {
position: relative;
white-space: nowrap;
&[data-badge],
&:not([data-badge]) {
&::after {
background: var(--primary-color);
background-clip: padding-box;
border-radius: 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: 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;
}
}
/* Badges for Buttons */
&.btn {
&::after {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%);
}
}
/* Badges for Avatars */
&.avatar {
&::after {
position: absolute;
top: 14.64%;
right: 14.64%;
transform: translate(50%, -50%);
z-index: var(--zindex-1);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
/* Code */
:root {
--code-bg-color: var(--body-color-contrast);
--code-color: var(--text-color);
}
code {
border-radius: var(--border-radius);
line-height: 1.25;
padding: 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;
& code {
color: inherit;
display: block;
line-height: 1.5;
overflow-x: auto;
padding: var(--unit-2);
width: 100%;
}
}

View File

@@ -0,0 +1,42 @@
/* Dropdown */
.dropdown {
--dropdown-focus-display: block;
display: inline-block;
position: relative;
.menu {
animation: fade-in 0.15s ease 1;
display: none;
left: 0;
max-height: 50vh;
overflow-y: auto;
position: absolute;
top: 100%;
}
&.dropdown-right {
.menu {
left: auto;
right: 0;
}
}
&: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;
}
/* Fix dropdown-toggle border radius in button groups */
.btn-group {
.dropdown-toggle:nth-last-child(2) {
border-bottom-right-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
}
}
}

View File

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

View File

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

View File

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

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