Compare commits

...

162 Commits

Author SHA1 Message Date
thR CIrcU5
c746afcf76 Bulk create HTML snapshots (#1132)
* Add option to create HTML snapshot for bulk edit

* Add the prerequisite for displaying the "Create HTML Snapshot" bulk action option

* Add test case

This test case covers the scenario where the bulk actions panel displays the corresponding options when the HTML snapshot feature is enabled.

* Use the existing `tasks.create_html_snapshots()` instead of the for loop

* Fix the exposure of `settings.LD_ENABLE_SNAPSHOTS` within `BookmarkListContext`

* add service tests

* cleanup context

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-08-12 23:06:23 +02:00
Sascha Ißbrücker
aaa0f6e119 Run formatter 2025-08-11 08:05:50 +02:00
Sascha Ißbrücker
cd215a9237 Create bundle from current search query (#1154) 2025-08-10 22:45:28 +02:00
Sascha Ißbrücker
1e56b0e6f3 Ignore tags that exceed length limit during import (#1153) 2025-08-10 15:05:10 +02:00
Sascha Ißbrücker
5cc8c9c010 Allow filtering feeds by bundle (#1152) 2025-08-10 12:59:55 +02:00
Pedro Lima
846808d870 Ignore tags with just whitespace (#1125) 2025-08-10 10:20:03 +02:00
Sascha Ißbrücker
6d9a694756 Wrap long titles in bookmark details modal (#1150) 2025-08-10 10:05:46 +02:00
Per Mortensen
de38e56b3f Add linkding-media-archiver to community.md (#1144)
Adds a new project link to the community page
2025-08-10 09:11:42 +02:00
dependabot[bot]
c6fb695af2 Bump astro from 5.7.13 to 5.12.8 in /docs (#1147)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.7.13 to 5.12.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@5.12.8/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.12.8
  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-08-10 09:09:09 +02:00
Per Mortensen
93faf70b37 Use filename when downloading asset through UI (#1146) 2025-08-10 08:38:18 +02:00
hkclark
5330252db9 Add Pocket migration to to community page (#1112)
* Add Pocket migration to to community page

* Fix order

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-07-23 03:17:45 +02:00
Ben Oakes
ef00d289f5 Add CloudBreak on Managed Hosting (#1079)
* Add CloudBreak on Managed Hosting

* Use new path
2025-07-23 03:15:26 +02:00
Sascha Ißbrücker
4e8318d0ae Improve bookmark form accessibility (#1116)
* Bump Django

* Render error messages in English

* Remove unused USE_L10N option

* Associate errors and help texts with form fields

* Make checkbox inputs clickable

* Change cancel button text

* Fix tests
2025-07-03 08:44:41 +02:00
Sascha Ißbrücker
a8623d11ef Update order when deleting bundle (#1114) 2025-07-01 07:09:02 +02:00
Sascha Ißbrücker
8cd992ca30 Show bookmark bundles in admin (#1110) 2025-06-25 19:37:34 +02:00
Sascha Ißbrücker
68c104ba54 Fix custom CSS not being used in reader mode (#1102) 2025-06-20 06:22:08 +02:00
hkclark
7a4236d179 Automatically compress uploads with gzip (#1087)
* Gzip .html upload, tests for .html & .gz uploads

* Gzip all file types that aren't already gzips

* Show filename of what user uploaded before compression

* Remove line I thought we need but we don't

* cleanup and fix tests

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-06-20 06:15:25 +02:00
Sascha Ißbrücker
e87304501f Add date and time to HTML export filename (#1101) 2025-06-20 06:01:15 +02:00
Sascha Ißbrücker
809e9e02f3 Update CHANGELOG.md 2025-06-20 00:38:18 +02:00
Sascha Ißbrücker
2bb33ff96d Bump version 2025-06-19 22:23:34 +02:00
Sascha Ißbrücker
549554cc17 Add REST API for bookmark bundles (#1100)
* Add bundles API

* Add docs
2025-06-19 22:19:29 +02:00
Peter
20e31397cc Add LinkBuddy to community section (#1088)
* Updates community resources to add LinkBuddy, an open-source React Native android and iOS app

* Fix ordering

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-06-19 17:40:29 +02:00
Sascha Ißbrücker
94ae5fb41c Fix assets not using correct icon (#1098) 2025-06-19 17:37:16 +02:00
dependabot[bot]
2a550e2315 Bump urllib3 from 2.2.3 to 2.5.0 (#1096)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.3 to 2.5.0.
- [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.2.3...2.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 17:21:32 +02:00
dependabot[bot]
a79e8bcd59 Bump requests from 2.32.3 to 2.32.4 (#1090)
Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  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-06-19 17:21:05 +02:00
dependabot[bot]
1710d44df7 Bump django from 5.1.9 to 5.1.10 (#1086)
Bumps [django](https://github.com/django/django) from 5.1.9 to 5.1.10.
- [Commits](https://github.com/django/django/compare/5.1.9...5.1.10)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.1.10
  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-06-19 17:20:54 +02:00
dependabot[bot]
9967b3e27b Bump tar-fs in /docs (#1084)
Bumps  and [tar-fs](https://github.com/mafintosh/tar-fs). These dependencies needed to be updated together.

Updates `tar-fs` from 2.1.2 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

Updates `tar-fs` from 3.0.8 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 17:20:40 +02:00
Sascha Ißbrücker
1672dc0152 Add bundles for organizing bookmarks (#1097)
* add bundle model and query logic

* cleanup tests

* add basic form

* add success message

* Add form tests

* Add bundle list view

* fix edit view

* Add remove button

* Add basic preview logic

* Make pagination use absolute URLs

* Hide bookmark edits when rendering preview

* Render bookmark list in preview

* Reorder bundles

* Show bundles in bookmark view

* Make bookmark search respect selected bundle

* UI tweaks

* Fix bookmark scope

* Improve bundle preview

* Skip preview if form is submitted

* Show correct preview after invalid form submission

* Add option to hide bundles

* Merge new migrations

* Add tests for bundle menu

* Improve check for preview being removed
2025-06-19 16:47:29 +02:00
Sascha Ißbrücker
8be72a5d1f Fix side panel not being hidden on smaller viewports (#1089) 2025-06-10 09:24:37 +02:00
Sascha Ißbrücker
bb796c9bdb Add date filters for REST API (#1080)
* Add modified_since query parameter

* Add added_since parameter

* update date_modified when assets change
2025-05-30 10:24:19 +02:00
Sascha Ißbrücker
578680c3c1 Fix docs build 2025-05-17 13:37:00 +02:00
Sascha Ißbrücker
8debb5c5aa Add install instructions for GHCR 2025-05-17 13:18:40 +02:00
Sascha Ißbrücker
be752f8146 Update CHANGELOG.md 2025-05-17 12:56:10 +02:00
Sascha Ißbrücker
e487cf726a Bump version 2025-05-17 10:53:04 +02:00
Bastian
f2800efc1a Allow pre-filling tags in new bookmark form (#1060)
* feat - Allow tag_string as query for BookmarkForm in order to set tags via bookmark snippets

* add test

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-05-17 10:13:07 +02:00
Johannes Zorn
9a00ae4b93 Add opensearch declaration (#1058)
* feat: Add opensearch declaration

* cleanup

---------

Co-authored-by: Johannes Zorn <johannes.zorn@zollsoft.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-05-17 09:52:26 +02:00
dependabot[bot]
da9371e33c Bump django from 5.1.8 to 5.1.9 (#1059)
Bumps [django](https://github.com/django/django) from 5.1.8 to 5.1.9.
- [Commits](https://github.com/django/django/compare/5.1.8...5.1.9)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.1.9
  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-05-17 09:03:58 +02:00
Jakob Krigovsky
5b3f2f6563 Linkify plain URLs in notes (#1051)
* Linkify plain URLs in notes

* add test case

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-05-17 09:03:40 +02:00
Kazi
04065f8079 Add how-to for using linkding PWA in native Android share sheet (#1055) 2025-05-17 08:58:29 +02:00
Vincent Ging Ho Yim
d986ff0900 Fix typo in index.mdx tagline (#1052) 2025-05-17 08:56:42 +02:00
dependabot[bot]
51a85bbaf1 Bump esbuild, @astrojs/starlight and astro in /docs (#1037)
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.25.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@astrojs/starlight](https://github.com/withastro/starlight/tree/HEAD/packages/starlight) and [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.25.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.2)

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

Updates `astro` from 4.16.18 to 5.6.0
- [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@5.6.0/packages/astro)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.2
  dependency-type: indirect
- dependency-name: "@astrojs/starlight"
  dependency-version: 0.32.5
  dependency-type: direct:production
- dependency-name: astro
  dependency-version: 5.6.0
  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-05-17 08:48:00 +02:00
dependabot[bot]
39b911880d Bump vite from 5.4.14 to 5.4.17 in /docs (#1036)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.14 to 5.4.17.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.17/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:33:58 +02:00
dependabot[bot]
9db3fa1248 Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs (#1035)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.26.7 to 7.27.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-version: 7.27.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:33:44 +02:00
dependabot[bot]
77689366a0 Bump prismjs from 1.29.0 to 1.30.0 in /docs (#1034)
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.29.0 to 1.30.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-version: 1.30.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:33:31 +02:00
Cayce House
f2e6014ca4 Push Docker images to GHCR in addition to Docker Hub (#1024) 2025-05-17 08:33:11 +02:00
haondt
da98929f07 Adding linktiles to community projects (#1025)
* added linktiles link

* switched to github link
2025-05-17 08:26:03 +02:00
Sascha Ißbrücker
1b0684bd6c Allow auto tagging rules to match URL fragments (#1045) 2025-04-13 09:43:11 +02:00
dependabot[bot]
8928c78530 Bump tar-fs in /docs (#1028)
Bumps  and [tar-fs](https://github.com/mafintosh/tar-fs). These dependencies needed to be updated together.

Updates `tar-fs` from 2.1.1 to 3.0.8
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.1...v3.0.8)

Updates `tar-fs` from 3.0.6 to 3.0.8
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.1...v3.0.8)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-type: indirect
- dependency-name: tar-fs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 21:17:32 +02:00
dependabot[bot]
61108234b4 Bump django from 5.1.7 to 5.1.8 (#1030)
Bumps [django](https://github.com/django/django) from 5.1.7 to 5.1.8.
- [Commits](https://github.com/django/django/compare/5.1.7...5.1.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>
2025-04-03 21:17:19 +02:00
Sascha Ißbrücker
7b098d4549 Fix bookmark asset download endpoint (#1033) 2025-04-03 21:16:59 +02:00
Sascha Ißbrücker
648e67bd91 Update troubleshooting.md 2025-03-22 22:19:23 +01:00
Sascha Ißbrücker
6bba4f35c8 Prefer local snapshot over web archive link in bookmark list links (#1021)
* Prefer local snapshot over web archive link

* Update latest snapshot when it is deleted

* fix filter in migration

* improve migration performance
2025-03-22 19:07:05 +01:00
Sascha Ißbrücker
6d9d0e19f1 Add E2E tests for refresh button 2025-03-22 12:16:48 +01:00
Josh S
a23c357f2f Add bulk and single bookmark metadata refresh (#999)
* Add url create/edit query paramter to clear cache

* Add refresh bookmark metadata button in create/edit bookmark page

* Fix refresh bookmark metadata when editing existing bookmark

* Add bulk refresh metadata functionality

* Fix test cases for bulk view dropdown selection list

* Allow bulk metadata refresh when background tasks are disabled

* Move load preview image call on refresh metadata

* Update bookmark modified time on metadata refresh

* Rename function to align with convention

* Add tests for refresh task

* Add tests for bookmarks service refresh metadata

* Add tests for bookmarks api disable cache on check

* Remove bulk refresh metadata when background tasks disabled

* Refactor refresh metadata task

* Remove unnecessary call

* Fix testing mock name

* Abstract clearing metadata cache

* Add test to check if load page is called twice when cache disabled

* Remove refresh button for new bookmarks

* Remove strict disable cache is true check

* Refactor refresh metadata form logic into its own function

* move button and highlight changes

* polish and update tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-03-22 11:34:10 +01:00
Jose Alvarez
f1acb4f7c9 Handle lowercase "true" in environment variables (#1020) 2025-03-22 08:27:35 +01:00
Sascha Ißbrücker
48fc499aed Add test for OIDC login link 2025-03-19 19:27:59 +01:00
Stefan Foerster
2a55800e18 Fix OIDC login link (#1019)
Fixes #1016.
2025-03-19 19:25:30 +01:00
Sascha Ißbrücker
e45dffb9cb Improve announcements after navigation (#1015) 2025-03-16 12:24:25 +01:00
Sascha Ißbrücker
226eb69f8b Accessibility improvements in page structure (#1014)
* Change app link to not use heading

* Use main and h1 for main content

* Update settings page structure

* Fix responsive styles

* Update bookmark form page structure

* Update auth page structure

* Add some basic page titles

* Expose side panel section

* Add page title for bookmark details

* Expose more sections

* Improve region names
2025-03-16 10:25:01 +01:00
Sascha Ißbrücker
b9bee24047 Move more logic into bookmark form 2025-03-09 23:11:48 +01:00
Sascha Ißbrücker
9dfc9b03b4 Fix E2E job 2025-03-09 12:30:10 +01:00
Sascha Ißbrücker
6ab6a031c7 Extract access checks 2025-03-09 12:21:22 +01:00
Sascha Ißbrücker
1a1092d03a Fix some type hints 2025-03-09 11:30:13 +01:00
Sascha Ißbrücker
4260dfce79 Remove duplicate URL calls in settings nav 2025-03-09 05:53:55 +01:00
Sascha Ißbrücker
2d3bd13a12 Merge siteroot application 2025-03-09 05:50:05 +01:00
Sascha Ißbrücker
b037de14c9 Move e2e tests 2025-03-09 05:46:26 +01:00
Sascha Ißbrücker
bbf173c135 Remove some duplication in bookmark routes 2025-03-09 05:45:50 +01:00
dependabot[bot]
002fec37d0 Bump django from 5.1.5 to 5.1.7 (#1007)
Bumps [django](https://github.com/django/django) from 5.1.5 to 5.1.7.
- [Commits](https://github.com/django/django/compare/5.1.5...5.1.7)

---
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-03-08 09:21:10 +01:00
Sascha Ißbrücker
996e2b6e19 Add docs for auto tagging (#1009) 2025-03-08 09:20:35 +01:00
Sascha Ißbrücker
6838e45e99 Update api.md 2025-03-06 21:44:10 +01:00
Sascha Ißbrücker
5b2a2c2b0d Bump version 2025-03-06 19:39:51 +01:00
Sascha Ißbrücker
988468f3e5 fix path context 2025-03-06 19:35:35 +01:00
Sascha Ißbrücker
3ac0503843 Add documentation for archiving web pages (#1004) 2025-03-06 19:13:35 +01:00
Sascha Ißbrücker
6d3755f46a Update version 2025-03-06 17:36:10 +01:00
Sascha Ißbrücker
25342e5fb6 Update version 2025-03-06 17:32:02 +01:00
Sascha Ißbrücker
be548a95a0 Update build job 2025-03-06 17:31:14 +01:00
Sascha Ißbrücker
978fba4cf5 Update build job 2025-03-06 09:48:04 +01:00
Sascha Ißbrücker
8a3572ba4b Add bookmark assets API (#1003)
* Add list, details and download endpoints

* Avoid using multiple DefaultRoute instances

* Add upload endpoint

* Add docs

* Allow configuring max request content length

* Add option for disabling uploads

* Remove gzip field

* Add delete endpoint
2025-03-06 09:09:53 +01:00
Sascha Ißbrücker
b21812c30a Update build job 2025-03-06 09:09:16 +01:00
Nick Sartor
72fbf6a590 Add linklater to community section (#1002)
Adding android client linklater to community projects md. Fixes #741
2025-03-04 11:33:56 +01:00
jvt
31ac796d6d Add Telegram bot to community section (#1001)
add my telegram bot project to send bookmarks directly to Linkding.
2025-03-04 11:31:45 +01:00
Sascha Ißbrücker
2d81ea6f6e Add REST endpoint for uploading snapshots from the Singlefile extension (#996)
* Extract asset logic

* Allow disabling HTML snapshot when creating bookmark

* Add endpoint for uploading singlefile snapshots

* Add URL parameter to disable HTML snapshots

* Allow using asset list in base Docker image

* Expose app version through profile
2025-02-23 22:58:14 +01:00
Sascha Ißbrücker
2e97b13bad Allow providing REST API authentication token with Bearer keyword (#995) 2025-02-22 19:59:53 +01:00
Sascha Ißbrücker
30f85103cd Update CHANGELOG.md 2025-02-22 19:51:00 +01:00
Sascha Ißbrücker
cfe4ff113d Bump version 2025-02-22 19:28:47 +01:00
Sascha Ißbrücker
757dc56277 Bump base images 2025-02-19 16:14:34 +01:00
Sascha Ißbrücker
dfbb367857 Fix auth proxy logout (#994) 2025-02-19 07:27:04 +01:00
Sascha Ißbrücker
2276832465 Return web archive fallback URL from REST API (#993) 2025-02-19 06:44:21 +01:00
Chris M
9d61bdce52 Add note about OIDC and LD_SUPERUSER_NAME combination (#992)
* docs: add note about OIDC and LD_SUPERUSER_NAME combination

Resolves #988

* tweak text

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-02-18 22:45:26 +01:00
Sascha Ißbrücker
1274a9ae0a Try limit uwsgi memory usage by configuring file descriptor limit (#990) 2025-02-15 08:49:58 +01:00
Sascha Ißbrücker
5e7172d17e Remove preview image when bookmark is deleted (#989) 2025-02-15 08:26:58 +01:00
Sascha Ißbrücker
78608135d9 Update CHANGELOG.md 2025-02-09 10:47:02 +01:00
Sascha Ißbrücker
51acd1da3f add build script 2025-02-08 18:20:15 +01:00
Sascha Ißbrücker
016ff2da66 Bump version 2025-02-08 10:54:46 +01:00
Sascha Ißbrücker
77d7e6e66a Add RSS link to shared bookmarks page (#984) 2025-02-08 10:51:17 +01:00
Sascha Ißbrücker
c5a300a435 Convert tag modal into drawer (#977)
* change tag modal into drawer

* improve scroll handling

* teleport all side bar content

* improve naming

* fix focus trap in filter drawer
2025-02-02 20:42:28 +01:00
Sascha Ißbrücker
0d4c47eb81 Add option to collapse side panel (#975) 2025-02-02 11:28:35 +01:00
Sascha Ißbrücker
17442eeb9a Improve accessibility of modal dialogs (#974)
* improve details modal accessibility

* improve tag modal accessibility

* fix overlays in archive and shared pages

* update tests

* use buttons for closing dialogs

* replace description list

* hide preview image from screen readers

* update tests
2025-02-02 00:28:17 +01:00
Kyuuk
2973812626 Allow customizing username when creating user through OIDC (#971)
* add ability to cutomize claim user for username generation on oidc login

* update documentation with new OIDC options

* oidc: also normalize custom claim as username

* improve tests

* improve docs

* some more cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-01-30 03:40:52 +01:00
Josh Dick
fc48b266a8 Add Additional iOS Shortcut to community section (#968) 2025-01-28 13:29:44 +01:00
Sascha Ißbrücker
7b42241026 Fix nav menu closing on mousedown in Safari (#965) 2025-01-27 09:05:19 +01:00
Sascha Ißbrücker
9c648dc67f Update CHANGELOG.md 2025-01-27 00:02:14 +01:00
Sascha Ißbrücker
1624128132 Bump versions to make Docker build 2025-01-26 23:52:36 +01:00
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
283 changed files with 21452 additions and 5641 deletions

View File

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

View File

@@ -3,7 +3,6 @@
# Include files required for build or at runtime # Include files required for build or at runtime
!/bookmarks !/bookmarks
!/siteroot
!/bootstrap.sh !/bootstrap.sh
!/LICENSE.txt !/LICENSE.txt
@@ -19,4 +18,4 @@
!/version.txt !/version.txt
# Remove dev settings # Remove dev settings
/siteroot/settings/dev.py /bookmarks/settings/dev.py

89
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,89 @@
name: build
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version from file
id: get_version
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build latest
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest
sissbruecker/linkding:${{ env.VERSION }}
ghcr.io/sissbruecker/linkding:latest
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
target: linkding
push: true
- name: Build latest-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-alpine
sissbruecker/linkding:${{ env.VERSION }}-alpine
ghcr.io/sissbruecker/linkding:latest-alpine
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
target: linkding
push: true
- name: Build latest-plus
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus
sissbruecker/linkding:${{ env.VERSION }}-plus
ghcr.io/sissbruecker/linkding:latest-plus
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
target: linkding-plus
push: true
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus-alpine
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
ghcr.io/sissbruecker/linkding:latest-plus-alpine
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
target: linkding-plus
push: true

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.10" python-version: "3.12"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -37,7 +37,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.10" python-version: "3.12"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -55,4 +55,4 @@ jobs:
npm run build npm run build
python manage.py collectstatic python manage.py collectstatic
- name: Run tests - name: Run tests
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py" run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,221 @@
# Changelog # Changelog
## v1.41.0 (19/06/2025)
### What's Changed
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
### New Contributors
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
---
## v1.40.0 (17/05/2025)
### What's Changed
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
### New Contributors
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
---
## v1.39.1 (06/03/2025)
> [!WARNING]
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
---
## v1.39.0 (06/03/2025)
### What's Changed
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
### New Contributors
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
---
## v1.38.1 (22/02/2025)
### What's Changed
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
### New Contributors
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
---
## v1.38.0 (09/02/2025)
### What's Changed
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
### New Contributors
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
---
## v1.37.0 (26/01/2025)
### What's Changed
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
### New Contributors
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
---
## v1.36.0 (02/10/2024)
### What's Changed
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
### New Contributors
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
---
## v1.35.0 (23/09/2024)
### What's Changed
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
### New Contributors
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
---
## v1.34.0 (16/09/2024)
### What's Changed
* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825
* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829
* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826
### New Contributors
* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0
---
## v1.33.0 (14/09/2024) ## v1.33.0 (14/09/2024)
### What's Changed ### What's Changed

View File

@@ -4,12 +4,12 @@ serve:
python manage.py runserver python manage.py runserver
tasks: tasks:
python manage.py process_tasks python manage.py run_huey
test: test:
pytest -n auto pytest -n auto
format: format:
black bookmarks black bookmarks
black siteroot
npx prettier bookmarks/frontend --write npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write

232
README.md
View File

@@ -1,25 +1,11 @@
<div align="center"> <div align="center">
<br> <br>
<a href="https://github.com/sissbruecker/linkding"> <a href="https://github.com/sissbruecker/linkding">
<img src="docs/header.svg" height="50"> <img src="assets/header.svg" height="50">
</a> </a>
<br> <br>
</div> </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 ## Introduction
linkding is a bookmark manager that you can host yourself. linkding is a bookmark manager that you can host yourself.
@@ -49,219 +35,33 @@ The name comes from:
**Screenshot:** **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 following links help you to get started with linkding:
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi. - [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)
linkding uses an SQLite database by default. - [Check out community projects](https://linkding.link/community), which include mobile apps, browser extensions, libraries and more
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)
## Documentation ## Documentation
| Document | Description | The full documentation is now available at [linkding.link](https://linkding.link/).
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| [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 |
## 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: 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).
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension). ## Contributing
## Community 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.
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
## Acknowledgements + Donations
### PikaPods
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
See the table below for a list of donations.
| Source | Description | Amount | Donated to |
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
### JetBrains
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
## Development ## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites ### Prerequisites
- Python 3.10 - Python 3.12
- Node.js - Node.js
### Setup ### Setup
@@ -305,7 +105,7 @@ The frontend is now available under http://localhost:8000
Run all tests with pytest: Run all tests with pytest:
``` ```
pytest make test
``` ```
### Formatting ### Formatting
@@ -317,7 +117,7 @@ make format
### DevContainers ### 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: Once checked out, only the following commands are required to get started:

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

41
assets/header.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

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

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -11,7 +11,15 @@ from huey.contrib.djhuey import HUEY as huey
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
Tag,
UserProfile,
Toast,
FeedToken,
)
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
) )
class AdminBookmarkBundle(admin.ModelAdmin):
list_display = (
"name",
"owner",
"order",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"date_created",
)
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
list_filter = ("owner__username",)
class AdminUserProfileInline(admin.StackedInline): class AdminUserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
can_delete = False can_delete = False
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset) linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(Toast, AdminToast)

34
bookmarks/api/auth.py Normal file
View File

@@ -0,0 +1,34 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication, get_authorization_header
class LinkdingTokenAuthentication(TokenAuthentication):
"""
Extends DRF TokenAuthentication to add support for multiple keywords
"""
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() not in self.keywords:
return None
if len(auth) == 1:
msg = _("Invalid token header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _("Invalid token header. Token string should not contain spaces.")
raise exceptions.AuthenticationFailed(msg)
try:
token = auth[1].decode()
except UnicodeError:
msg = _(
"Invalid token header. Token string should not contain invalid characters."
)
raise exceptions.AuthenticationFailed(msg)
return self.authenticate_credentials(token)

View File

@@ -1,25 +1,34 @@
import gzip
import logging import logging
import os
from django.conf import settings
from django.http import Http404, StreamingHttpResponse
from rest_framework import viewsets, mixins, status from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import DefaultRouter from rest_framework.routers import SimpleRouter, DefaultRouter
from bookmarks import queries from bookmarks import queries
from bookmarks.api.serializers import ( from bookmarks.api.serializers import (
BookmarkSerializer, BookmarkSerializer,
BookmarkAssetSerializer,
TagSerializer, TagSerializer,
UserProfileSerializer, UserProfileSerializer,
BookmarkBundleSerializer,
) )
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User from bookmarks.models import (
from bookmarks.services import auto_tagging Bookmark,
from bookmarks.services.bookmarks import ( BookmarkAsset,
archive_bookmark, BookmarkSearch,
unarchive_bookmark, Tag,
website_loader, User,
BookmarkBundle,
) )
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.type_defs import HttpRequest
from bookmarks.views import access
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,6 +41,7 @@ class BookmarkViewSet(
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
): ):
request: HttpRequest
serializer_class = BookmarkSerializer serializer_class = BookmarkSerializer
def get_permissions(self): def get_permissions(self):
@@ -46,71 +56,63 @@ class BookmarkViewSet(
return super().get_permissions() return super().get_permissions()
def get_queryset(self): def get_queryset(self):
# Provide filtered queryset for list actions
user = self.request.user user = self.request.user
# For list action, use query set that applies search and tag projections search = BookmarkSearch.from_request(self.request, self.request.GET)
if self.action == "list": if self.action == "list":
search = BookmarkSearch.from_request(self.request.GET)
return queries.query_bookmarks(user, user.profile, search) return queries.query_bookmarks(user, user.profile, search)
elif self.action == "archived":
return queries.query_archived_bookmarks(user, user.profile, search)
elif self.action == "shared":
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, search, public_only
)
# For single entity actions use default query set without projections # For single entity actions return user owned bookmarks
return Bookmark.objects.all().filter(owner=user) return Bookmark.objects.all().filter(owner=user)
def get_serializer_context(self): def get_serializer_context(self):
return {"request": self.request, "user": self.request.user} disable_scraping = "disable_scraping" in self.request.GET
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
return {
"request": self.request,
"user": self.request.user,
"disable_scraping": disable_scraping,
"disable_html_snapshot": disable_html_snapshot,
}
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def archived(self, request): def archived(self, request: HttpRequest):
user = request.user return self.list(request)
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def shared(self, request): def shared(self, request: HttpRequest):
search = BookmarkSearch.from_request(request.GET) return self.list(request)
user = User.objects.filter(username=search.user).first()
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(
user, request.user_profile, search, public_only
)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["post"], detail=True) @action(methods=["post"], detail=True)
def archive(self, request, pk): def archive(self, request: HttpRequest, pk):
bookmark = self.get_object() bookmark = self.get_object()
archive_bookmark(bookmark) bookmarks.archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["post"], detail=True) @action(methods=["post"], detail=True)
def unarchive(self, request, pk): def unarchive(self, request: HttpRequest, pk):
bookmark = self.get_object() bookmark = self.get_object()
unarchive_bookmark(bookmark) bookmarks.unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def check(self, request): def check(self, request: HttpRequest):
url = request.GET.get("url") url = request.GET.get("url")
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = ( existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None self.get_serializer(bookmark).data if bookmark else None
) )
# Either return metadata from existing bookmark, or scrape from URL metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)
if bookmark:
metadata = WebsiteMetadata(
url,
bookmark.website_title,
bookmark.website_description,
None,
)
else:
metadata = website_loader.load_website_metadata(url)
# Return tags that would be automatically applied to the bookmark # Return tags that would be automatically applied to the bookmark
profile = request.user.profile profile = request.user.profile
@@ -120,7 +122,7 @@ class BookmarkViewSet(
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url) auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}", f"Failed to auto-tag bookmark. url={url}",
exc_info=e, exc_info=e,
) )
@@ -133,6 +135,116 @@ class BookmarkViewSet(
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@action(methods=["post"], detail=False)
def singlefile(self, request: HttpRequest):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
url = request.POST.get("url")
file = request.FILES.get("file")
if not url or not file:
return Response(
{"error": "Both 'url' and 'file' parameters are required."},
status=status.HTTP_400_BAD_REQUEST,
)
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
if not bookmark:
bookmark = Bookmark(url=url)
bookmark = bookmarks.create_bookmark(
bookmark, "", request.user, disable_html_snapshot=True
)
bookmarks.enhance_with_website_metadata(bookmark)
assets.upload_snapshot(bookmark, file.read())
return Response(
{"message": "Snapshot uploaded successfully."},
status=status.HTTP_201_CREATED,
)
class BookmarkAssetViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
request: HttpRequest
serializer_class = BookmarkAssetSerializer
def get_queryset(self):
user = self.request.user
# limit access to assets to the owner of the bookmark for now
bookmark = access.bookmark_write(self.request, self.kwargs["bookmark_id"])
return BookmarkAsset.objects.filter(
bookmark_id=bookmark.id, bookmark__owner=user
)
def get_serializer_context(self):
return {"user": self.request.user}
@action(detail=True, methods=["get"], url_path="download")
def download(self, request: HttpRequest, bookmark_id, pk):
asset = self.get_object()
try:
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
content_type = asset.content_type
file_stream = (
gzip.GzipFile(file_path, mode="rb")
if asset.gzip
else open(file_path, "rb")
)
response = StreamingHttpResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = (
f'attachment; filename="{asset.download_name}"'
)
return response
except FileNotFoundError:
raise Http404("Asset file does not exist")
except Exception as e:
logger.error(
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
exc_info=e,
)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(methods=["post"], detail=False)
def upload(self, request: HttpRequest, bookmark_id):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
bookmark = access.bookmark_write(request, bookmark_id)
upload_file = request.FILES.get("file")
if not upload_file:
return Response(
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
)
try:
asset = assets.upload_asset(bookmark, upload_file)
serializer = self.get_serializer(asset)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
exc_info=e,
)
return Response(
{"error": "Failed to upload asset."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def perform_destroy(self, instance):
assets.remove_asset(instance)
class TagViewSet( class TagViewSet(
viewsets.GenericViewSet, viewsets.GenericViewSet,
@@ -140,6 +252,7 @@ class TagViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.CreateModelMixin, mixins.CreateModelMixin,
): ):
request: HttpRequest
serializer_class = TagSerializer serializer_class = TagSerializer
def get_queryset(self): def get_queryset(self):
@@ -152,11 +265,48 @@ class TagViewSet(
class UserViewSet(viewsets.GenericViewSet): class UserViewSet(viewsets.GenericViewSet):
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def profile(self, request): def profile(self, request: HttpRequest):
return Response(UserProfileSerializer(request.user.profile).data) return Response(UserProfileSerializer(request.user.profile).data)
router = DefaultRouter() class BookmarkBundleViewSet(
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark") viewsets.GenericViewSet,
router.register(r"tags", TagViewSet, basename="tag") mixins.ListModelMixin,
router.register(r"user", UserViewSet, basename="user") mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
request: HttpRequest
serializer_class = BookmarkBundleSerializer
def get_queryset(self):
user = self.request.user
return BookmarkBundle.objects.filter(owner=user).order_by("order")
def get_serializer_context(self):
return {"user": self.request.user}
def perform_destroy(self, instance):
bundles.delete_bundle(instance)
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
# Instead create separate routers for each view set and manually register them in urls.py
# The default router is only used to allow reversing a URL for the API root
default_router = DefaultRouter()
bookmark_router = SimpleRouter()
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
tag_router = SimpleRouter()
tag_router.register("", TagViewSet, basename="tag")
user_router = SimpleRouter()
user_router.register("", UserViewSet, basename="user")
bundle_router = SimpleRouter()
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
bookmark_asset_router = SimpleRouter()
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")

View File

@@ -1,11 +1,20 @@
from django.db.models import prefetch_related_objects from django.db.models import Max, prefetch_related_objects
from django.templatetags.static import static from django.templatetags.static import static
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ListSerializer from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile from bookmarks.models import (
from bookmarks.services.bookmarks import create_bookmark, update_bookmark Bookmark,
BookmarkAsset,
Tag,
build_tag_string,
UserProfile,
BookmarkBundle,
)
from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version
class TagListField(serializers.ListField): class TagListField(serializers.ListField):
@@ -20,6 +29,37 @@ class BookmarkListSerializer(ListSerializer):
return super().to_representation(data) return super().to_representation(data)
class EmtpyField(serializers.ReadOnlyField):
def to_representation(self, value):
return None
class BookmarkBundleSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkBundle
fields = [
"id",
"name",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"order",
"date_created",
"date_modified",
]
read_only_fields = [
"id",
"date_created",
"date_modified",
]
def create(self, validated_data):
bundle = BookmarkBundle(**validated_data)
bundle.order = validated_data["order"] if "order" in validated_data else None
return bundles.create_bundle(bundle, self.context["user"])
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Bookmark model = Bookmark
@@ -29,8 +69,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
"title", "title",
"description", "description",
"notes", "notes",
"website_title",
"website_description",
"web_archive_snapshot_url", "web_archive_snapshot_url",
"favicon_url", "favicon_url",
"preview_image_url", "preview_image_url",
@@ -40,29 +78,30 @@ class BookmarkSerializer(serializers.ModelSerializer):
"tag_names", "tag_names",
"date_added", "date_added",
"date_modified", "date_modified",
]
read_only_fields = [
"website_title", "website_title",
"website_description", "website_description",
]
read_only_fields = [
"web_archive_snapshot_url", "web_archive_snapshot_url",
"favicon_url", "favicon_url",
"preview_image_url", "preview_image_url",
"tag_names",
"date_added", "date_added",
"date_modified", "date_modified",
"website_title",
"website_description",
] ]
list_serializer_class = BookmarkListSerializer list_serializer_class = BookmarkListSerializer
# Override optional char fields to provide default value # Custom tag_names field to allow passing a list of tag names to create/update
title = serializers.CharField(required=False, allow_blank=True, default="") tag_names = TagListField(required=False)
description = serializers.CharField(required=False, allow_blank=True, default="") # Custom fields to generate URLs for favicon, preview image, and web archive snapshot
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=[])
favicon_url = serializers.SerializerMethodField() favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField() preview_image_url = serializers.SerializerMethodField()
web_archive_snapshot_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = EmtpyField()
website_description = EmtpyField()
def get_favicon_url(self, obj: Bookmark): def get_favicon_url(self, obj: Bookmark):
if not obj.favicon_file: if not obj.favicon_file:
@@ -80,30 +119,75 @@ class BookmarkSerializer(serializers.ModelSerializer):
preview_image_url = request.build_absolute_uri(preview_image_file_path) preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url return preview_image_url
def get_web_archive_snapshot_url(self, obj: Bookmark):
if obj.web_archive_snapshot_url:
return obj.web_archive_snapshot_url
return generate_fallback_webarchive_url(obj.url, obj.date_added)
def create(self, validated_data): def create(self, validated_data):
bookmark = Bookmark() tag_names = validated_data.pop("tag_names", [])
bookmark.url = validated_data["url"] tag_string = build_tag_string(tag_names)
bookmark.title = validated_data["title"] bookmark = Bookmark(**validated_data)
bookmark.description = validated_data["description"]
bookmark.notes = validated_data["notes"] disable_scraping = self.context.get("disable_scraping", False)
bookmark.is_archived = validated_data["is_archived"] disable_html_snapshot = self.context.get("disable_html_snapshot", False)
bookmark.unread = validated_data["unread"]
bookmark.shared = validated_data["shared"] saved_bookmark = bookmarks.create_bookmark(
tag_string = build_tag_string(validated_data["tag_names"]) bookmark,
return create_bookmark(bookmark, tag_string, self.context["user"]) tag_string,
self.context["user"],
disable_html_snapshot=disable_html_snapshot,
)
# Unless scraping is explicitly disabled, enhance bookmark with website
# metadata to preserve backwards compatibility with clients that expect
# title and description to be populated automatically when left empty
if not disable_scraping:
bookmarks.enhance_with_website_metadata(saved_bookmark)
return saved_bookmark
def update(self, instance: Bookmark, validated_data): def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload tag_names = validated_data.pop("tag_names", instance.tag_names)
for key in ["url", "title", "description", "notes", "unread", "shared"]: tag_string = build_tag_string(tag_names)
if key in validated_data:
setattr(instance, key, validated_data[key])
# Use tag string from payload, or use bookmark's current tags as fallback for field_name, field in self.fields.items():
tag_string = build_tag_string(instance.tag_names) if not field.read_only and field_name in validated_data:
if "tag_names" in validated_data: setattr(instance, field_name, validated_data[field_name])
tag_string = build_tag_string(validated_data["tag_names"])
return update_bookmark(instance, tag_string, self.context["user"]) return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
def validate(self, attrs):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead. When editing a bookmark,
# there is no assumption that it would update a different bookmark if
# the URL is a duplicate, so raise a validation error in that case.
if self.instance and "url" in attrs:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise serializers.ValidationError(
{"url": "A bookmark with this URL already exists."}
)
return attrs
class BookmarkAssetSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkAsset
fields = [
"id",
"bookmark",
"date_created",
"file_size",
"asset_type",
"content_type",
"display_name",
"status",
]
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
@@ -131,4 +215,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
"display_url", "display_url",
"permanent_notes", "permanent_notes",
"search_preferences", "search_preferences",
"version",
] ]
version = serializers.ReadOnlyField(default=app_version)

View File

@@ -1,6 +1,5 @@
from bookmarks import queries
from bookmarks.models import BookmarkSearch, Toast
from bookmarks import utils from bookmarks import utils
from bookmarks.models import Toast
def toasts(request): def toasts(request):

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

@@ -8,6 +8,7 @@ from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
from bookmarks.views import access
@dataclass @dataclass
@@ -30,10 +31,16 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed): class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str | None): def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
bundle = None
bundle_id = request.GET.get("bundle")
if bundle_id:
bundle = access.bundle_read(request, bundle_id)
search = BookmarkSearch( search = BookmarkSearch(
q=request.GET.get("q", ""), q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""), unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""), shared=request.GET.get("shared", ""),
bundle=bundle,
) )
query_set = self.get_query_set(feed_token, search) query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set) return FeedContext(request, feed_token, query_set)
@@ -74,7 +81,7 @@ class AllBookmarksFeed(BaseBookmarksFeed):
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search) return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.all", args=[context.feed_token.key]) return reverse("linkding:feeds.all", args=[context.feed_token.key])
class UnreadBookmarksFeed(BaseBookmarksFeed): class UnreadBookmarksFeed(BaseBookmarksFeed):
@@ -87,7 +94,7 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
).filter(unread=True) ).filter(unread=True)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key]) return reverse("linkding:feeds.unread", args=[context.feed_token.key])
class SharedBookmarksFeed(BaseBookmarksFeed): class SharedBookmarksFeed(BaseBookmarksFeed):
@@ -100,7 +107,7 @@ class SharedBookmarksFeed(BaseBookmarksFeed):
) )
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key]) return reverse("linkding:feeds.shared", args=[context.feed_token.key])
class PublicSharedBookmarksFeed(BaseBookmarksFeed): class PublicSharedBookmarksFeed(BaseBookmarksFeed):
@@ -114,4 +121,4 @@ class PublicSharedBookmarksFeed(BaseBookmarksFeed):
return queries.query_shared_bookmarks(None, UserProfile(), search, True) return queries.query_shared_bookmarks(None, UserProfile(), search, True)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared") return reverse("linkding:feeds.public_shared")

102
bookmarks/forms.py Normal file
View File

@@ -0,0 +1,102 @@
from django import forms
from django.forms.utils import ErrorList
from bookmarks.models import Bookmark, build_tag_string
from bookmarks.validators import BookmarkURLValidator
from bookmarks.type_defs import HttpRequest
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = [
"url",
"tag_string",
"title",
"description",
"notes",
"unread",
"shared",
"auto_close",
]
def __init__(self, request: HttpRequest, instance: Bookmark = None):
self.request = request
initial = None
if instance is None and request.method == "GET":
initial = {
"url": request.GET.get("url"),
"title": request.GET.get("title"),
"description": request.GET.get("description"),
"notes": request.GET.get("notes"),
"tag_string": request.GET.get("tags"),
"auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread,
}
if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None
super().__init__(
data, instance=instance, initial=initial, error_class=CustomErrorList
)
@property
def is_auto_close(self):
return self.data.get("auto_close", False) == "True" or self.initial.get(
"auto_close", False
)
@property
def has_notes(self):
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
def save(self, commit=False):
tag_string = convert_tag_string(self.data["tag_string"])
bookmark = super().save(commit=False)
if self.instance.pk:
return update_bookmark(bookmark, tag_string, self.request.user)
else:
return create_bookmark(bookmark, tag_string, self.request.user)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings
return tag_string.replace(" ", ",")

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

@@ -1,60 +1,22 @@
import { Behavior, registerBehavior } from "./index"; import { registerBehavior } from "./index";
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends Behavior { class DetailsModalBehavior extends ModalBehavior {
constructor(element) { doClose() {
super(element); super.doClose();
this.onClose = this.onClose.bind(this); // Navigate to close URL
this.onKeyDown = this.onKeyDown.bind(this); const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
this.overlayLink = element.querySelector("a:has(.modal-overlay)"); // Try restore focus to view details to view details link of respective bookmark
this.buttonLink = element.querySelector("a:has(button.close)"); const bookmarkId = this.element.dataset.bookmarkId;
setAfterPageLoadFocusTarget(
this.overlayLink.addEventListener("click", this.onClose); `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
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 },
); );
} }
} }

View File

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

View File

@@ -0,0 +1,97 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector(".side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector(".side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -0,0 +1,124 @@
let keyboardActive = false;
window.addEventListener(
"keydown",
() => {
keyboardActive = true;
},
{ capture: true },
);
window.addEventListener(
"mousedown",
() => {
keyboardActive = false;
},
{ capture: true },
);
export function isKeyboardActive() {
return keyboardActive;
}
export class FocusTrapController {
constructor(element) {
this.element = element;
this.focusableElements = this.element.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
);
this.firstFocusableElement = this.focusableElements[0];
this.lastFocusableElement =
this.focusableElements[this.focusableElements.length - 1];
this.onKeyDown = this.onKeyDown.bind(this);
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
if (event.key !== "Tab") {
return;
}
if (event.shiftKey) {
if (document.activeElement === this.firstFocusableElement) {
event.preventDefault();
this.lastFocusableElement.focus();
}
} else {
if (document.activeElement === this.lastFocusableElement) {
event.preventDefault();
this.firstFocusableElement.focus();
}
}
}
}
let afterPageLoadFocusTarget = [];
let firstPageLoad = true;
export function setAfterPageLoadFocusTarget(...targets) {
afterPageLoadFocusTarget = targets;
}
function programmaticFocus(element) {
// Ensure element is focusable
// Hide focus outline if element is not focusable by default - might
// reconsider this later
const isFocusable = element.tabIndex >= 0;
if (!isFocusable) {
// Apparently the default tabIndex is -1, even though an element is still
// not focusable with that. Setting an explicit -1 also sets the attribute
// and the element becomes focusable.
element.tabIndex = -1;
// `focusVisible` is not supported in all browsers, so hide the outline manually
element.style["outline"] = "none";
}
element.focus({
focusVisible: isKeyboardActive() && isFocusable,
preventScroll: true,
});
}
// Register global listener for navigation and try to focus an element that
// results in a meaningful announcement.
document.addEventListener("turbo:load", () => {
// Ignore initial page load to let the browser handle announcements
if (firstPageLoad) {
firstPageLoad = false;
return;
}
// Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target);
if (element) {
programmaticFocus(element);
return;
}
}
afterPageLoadFocusTarget = [];
// If there is some autofocus element, let the browser handle it
const autofocus = document.querySelector("[autofocus]");
if (autofocus) {
return;
}
// If there is a toast as a result of some action, focus it
const toast = document.querySelector(".toast");
if (toast) {
programmaticFocus(toast);
return;
}
// Otherwise go with main
const main = document.querySelector("main");
if (main) {
programmaticFocus(main);
}
});

View File

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

View File

@@ -0,0 +1,91 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
this.element.remove();
this.clearInert();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
name: input.name, name: input.name,
value: input.value, value: input.value,
placeholder: input.getAttribute("placeholder") || "", placeholder: input.getAttribute("placeholder") || "",
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
variant: input.getAttribute("variant"), variant: input.getAttribute("variant"),
}, },
}); });

View File

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

View File

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

View File

@@ -6,6 +6,7 @@
export let name; export let name;
export let value; export let value;
export let placeholder; export let placeholder;
export let ariaDescribedBy;
export let variant = 'default'; export let variant = 'default';
let isFocus = false; let isFocus = false;
@@ -77,6 +78,7 @@
const bounds = getCurrentWordBounds(input); const bounds = getCurrentWordBounds(input);
const value = input.value; const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end); input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
close(); close();
} }
@@ -109,6 +111,7 @@
<!-- autocomplete real input box --> <!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}" <input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off" class="form-input" type="text" autocomplete="off" autocapitalize="off"
aria-describedby="{ariaDescribedBy}"
on:input={handleInput} on:keydown={handleKeyDown} on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}> on:focus={handleFocus} on:blur={handleBlur}>
</div> </div>
@@ -128,41 +131,41 @@
</div> </div>
<style> <style>
.menu { .menu {
display: none; display: none;
max-height: 200px; max-height: 200px;
overflow: auto; overflow: auto;
} }
.menu.open { .menu.open {
display: block; display: block;
} }
.form-autocomplete-input { .form-autocomplete-input {
box-sizing: border-box; box-sizing: border-box;
height: var(--control-size); height: var(--control-size);
min-height: var(--control-size); min-height: var(--control-size);
padding: 0; padding: 0;
} }
.form-autocomplete-input input { .form-autocomplete-input input {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: none; border: none;
margin: 0; margin: 0;
} }
.form-autocomplete.small .form-autocomplete-input { .form-autocomplete.small .form-autocomplete-input {
height: var(--control-size-sm); height: var(--control-size-sm);
min-height: var(--control-size-sm); min-height: var(--control-size-sm);
} }
.form-autocomplete.small .form-autocomplete-input input { .form-autocomplete.small .form-autocomplete-input input {
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.form-autocomplete.small .menu .menu-item { .form-autocomplete.small .menu .menu-item {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
</style> </style>

View File

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

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

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0042_userprofile_custom_css_hash"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="collapse_side_panel",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.1.7 on 2025-03-22 12:28
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import OuterRef, Subquery
def forwards(apps, schema_editor):
# Update the latest snapshot for each bookmark
Bookmark = apps.get_model("bookmarks", "bookmark")
BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset")
latest_snapshots = (
BookmarkAsset.objects.filter(
bookmark=OuterRef("pk"), asset_type="snapshot", status="complete"
)
.order_by("-date_created")
.values("id")[:1]
)
Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0043_userprofile_collapse_side_panel"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="latest_snapshot",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="latest_snapshot",
to="bookmarks.bookmarkasset",
),
),
migrations.RunPython(forwards, reverse),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.1.9 on 2025-06-19 08:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0044_bookmark_latest_snapshot"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="hide_bundles",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="BookmarkBundle",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=256)),
("search", models.CharField(blank=True, max_length=256)),
("any_tags", models.CharField(blank=True, max_length=1024)),
("all_tags", models.CharField(blank=True, max_length=1024)),
("excluded_tags", models.CharField(blank=True, max_length=1024)),
("order", models.IntegerField(default=0)),
("date_created", models.DateTimeField(auto_now_add=True)),
("date_modified", models.DateTimeField(auto_now=True)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -1,12 +1,14 @@
import binascii
import hashlib
import logging import logging
import os import os
from functools import cached_property
from typing import List from typing import List
import binascii
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
@@ -21,7 +23,7 @@ logger = logging.getLogger(__name__)
class Tag(models.Model): class Tag(models.Model):
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
date_added = models.DateTimeField() date_added = models.DateTimeField()
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -38,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
return [] return []
names = tag_string.strip().split(delimiter) names = tag_string.strip().split(delimiter)
# remove empty names, sanitize remaining names # remove empty names, sanitize remaining names
names = [sanitize_tag_name(name) for name in names if name] names = [sanitize_tag_name(name) for name in names if name.strip()]
# remove duplicates # remove duplicates
names = unique(names, str.lower) names = unique(names, str.lower)
names.sort(key=str.lower) names.sort(key=str.lower)
@@ -55,7 +57,9 @@ class Bookmark(models.Model):
title = models.CharField(max_length=512, blank=True) title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
notes = 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) 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) website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
favicon_file = models.CharField(max_length=512, blank=True) favicon_file = models.CharField(max_length=512, blank=True)
@@ -66,21 +70,26 @@ class Bookmark(models.Model):
date_added = models.DateTimeField() date_added = models.DateTimeField()
date_modified = models.DateTimeField() date_modified = models.DateTimeField()
date_accessed = models.DateTimeField(blank=True, null=True) date_accessed = models.DateTimeField(blank=True, null=True)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag) tags = models.ManyToManyField(Tag)
latest_snapshot = models.ForeignKey(
"BookmarkAsset",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="latest_snapshot",
)
@property @property
def resolved_title(self): def resolved_title(self):
if self.title: if self.title:
return self.title return self.title
elif self.website_title:
return self.website_title
else: else:
return self.url return self.url
@property @property
def resolved_description(self): def resolved_description(self):
return self.website_description if not self.description else self.description return self.description
@property @property
def tag_names(self): def tag_names(self):
@@ -91,6 +100,19 @@ class Bookmark(models.Model):
return self.resolved_title + " (" + self.url[:30] + "...)" return self.resolved_title + " (" + self.url[:30] + "...)"
@receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs):
if instance.preview_image_file:
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
if os.path.isfile(filepath):
try:
os.remove(filepath)
except Exception as error:
logger.error(
f"Failed to delete preview image: {filepath}", exc_info=error
)
class BookmarkAsset(models.Model): class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot" TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload" TYPE_UPLOAD = "upload"
@@ -111,6 +133,14 @@ class BookmarkAsset(models.Model):
status = models.CharField(max_length=64, blank=False, null=False) status = models.CharField(max_length=64, blank=False, null=False)
gzip = models.BooleanField(default=False, null=False) gzip = models.BooleanField(default=False, null=False)
@property
def download_name(self):
return (
f"{self.display_name}.html"
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else self.display_name
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.file: if self.file:
try: try:
@@ -136,43 +166,25 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error) logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
class BookmarkForm(forms.ModelForm): class BookmarkBundle(models.Model):
# Use URLField for URL name = models.CharField(max_length=256, blank=False)
url = forms.CharField(validators=[BookmarkURLValidator()]) search = models.CharField(max_length=256, blank=True)
tag_string = forms.CharField(required=False) any_tags = models.CharField(max_length=1024, blank=True)
# Do not require title and description in form as we fill these automatically if they are empty all_tags = models.CharField(max_length=1024, blank=True)
title = forms.CharField(max_length=512, required=False) excluded_tags = models.CharField(max_length=1024, blank=True)
description = forms.CharField(required=False, widget=forms.Textarea()) order = models.IntegerField(null=False, default=0)
# Include website title and description as hidden field as they only provide info when editing bookmarks date_created = models.DateTimeField(auto_now_add=True, null=False)
website_title = forms.CharField( date_modified = models.DateTimeField(auto_now=True, null=False)
max_length=512, required=False, widget=forms.HiddenInput() owner = models.ForeignKey(User, on_delete=models.CASCADE)
)
website_description = forms.CharField(required=False, widget=forms.HiddenInput())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
def __str__(self):
return self.name
class BookmarkBundleForm(forms.ModelForm):
class Meta: class Meta:
model = Bookmark model = BookmarkBundle
fields = [ fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
"url",
"tag_string",
"title",
"description",
"notes",
"website_title",
"website_description",
"unread",
"shared",
"auto_close",
]
@property
def has_notes(self):
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
class BookmarkSearch: class BookmarkSearch:
@@ -189,34 +201,54 @@ class BookmarkSearch:
FILTER_UNREAD_YES = "yes" FILTER_UNREAD_YES = "yes"
FILTER_UNREAD_NO = "no" FILTER_UNREAD_NO = "no"
params = ["q", "user", "sort", "shared", "unread"] params = [
"q",
"user",
"bundle",
"sort",
"shared",
"unread",
"modified_since",
"added_since",
]
preferences = ["sort", "shared", "unread"] preferences = ["sort", "shared", "unread"]
defaults = { defaults = {
"q": "", "q": "",
"user": "", "user": "",
"bundle": None,
"sort": SORT_ADDED_DESC, "sort": SORT_ADDED_DESC,
"shared": FILTER_SHARED_OFF, "shared": FILTER_SHARED_OFF,
"unread": FILTER_UNREAD_OFF, "unread": FILTER_UNREAD_OFF,
"modified_since": None,
"added_since": None,
} }
def __init__( def __init__(
self, self,
q: str = None, q: str = None,
user: str = None, user: str = None,
bundle: BookmarkBundle = None,
sort: str = None, sort: str = None,
shared: str = None, shared: str = None,
unread: str = None, unread: str = None,
modified_since: str = None,
added_since: str = None,
preferences: dict = None, preferences: dict = None,
request: any = None,
): ):
if not preferences: if not preferences:
preferences = {} preferences = {}
self.defaults = {**BookmarkSearch.defaults, **preferences} self.defaults = {**BookmarkSearch.defaults, **preferences}
self.request = request
self.q = q or self.defaults["q"] self.q = q or self.defaults["q"]
self.user = user or self.defaults["user"] self.user = user or self.defaults["user"]
self.bundle = bundle or self.defaults["bundle"]
self.sort = sort or self.defaults["sort"] self.sort = sort or self.defaults["sort"]
self.shared = shared or self.defaults["shared"] self.shared = shared or self.defaults["shared"]
self.unread = unread or self.defaults["unread"] self.unread = unread or self.defaults["unread"]
self.modified_since = modified_since or self.defaults["modified_since"]
self.added_since = added_since or self.defaults["added_since"]
def is_modified(self, param): def is_modified(self, param):
value = self.__dict__[param] value = self.__dict__[param]
@@ -244,7 +276,14 @@ class BookmarkSearch:
@property @property
def query_params(self): def query_params(self):
return {param: self.__dict__[param] for param in self.modified_params} query_params = {}
for param in self.modified_params:
value = self.__dict__[param]
if isinstance(value, models.Model):
query_params[param] = value.id
else:
query_params[param] = value
return query_params
@property @property
def preferences_dict(self): def preferences_dict(self):
@@ -253,14 +292,21 @@ class BookmarkSearch:
} }
@staticmethod @staticmethod
def from_request(query_dict: QueryDict, preferences: dict = None): def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
initial_values = {} initial_values = {}
for param in BookmarkSearch.params: for param in BookmarkSearch.params:
value = query_dict.get(param) value = query_dict.get(param)
if value: if value:
initial_values[param] = value if param == "bundle":
initial_values[param] = BookmarkBundle.objects.filter(
owner=request.user, pk=value
).first()
else:
initial_values[param] = value
return BookmarkSearch(**initial_values, preferences=preferences) return BookmarkSearch(
**initial_values, preferences=preferences, request=request
)
class BookmarkSearchForm(forms.Form): class BookmarkSearchForm(forms.Form):
@@ -283,9 +329,12 @@ class BookmarkSearchForm(forms.Form):
q = forms.CharField() q = forms.CharField()
user = forms.ChoiceField(required=False) user = forms.ChoiceField(required=False)
bundle = forms.CharField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES) sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect) shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect) unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
modified_since = forms.CharField(required=False)
added_since = forms.CharField(required=False)
def __init__( def __init__(
self, self,
@@ -305,7 +354,11 @@ class BookmarkSearchForm(forms.Form):
for param in search.params: for param in search.params:
# set initial values for modified params # set initial values for modified params
self.fields[param].initial = search.__dict__[param] value = search.__dict__.get(param)
if isinstance(value, models.Model):
self.fields[param].initial = value.id
else:
self.fields[param].initial = value
# Mark non-editable modified fields as hidden. That way, templates # Mark non-editable modified fields as hidden. That way, templates
# rendering a form can just loop over hidden_fields to ensure that # rendering a form can just loop over hidden_fields to ensure that
@@ -361,9 +414,7 @@ class UserProfile(models.Model):
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"), (TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
(TAG_GROUPING_DISABLED, "Disabled"), (TAG_GROUPING_DISABLED, "Disabled"),
] ]
user = models.OneToOneField( user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
get_user_model(), related_name="profile", on_delete=models.CASCADE
)
theme = models.CharField( theme = models.CharField(
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
) )
@@ -418,10 +469,26 @@ class UserProfile(models.Model):
display_remove_bookmark_action = models.BooleanField(default=True, null=False) display_remove_bookmark_action = models.BooleanField(default=True, null=False)
permanent_notes = models.BooleanField(default=False, null=False) permanent_notes = models.BooleanField(default=False, null=False)
custom_css = models.TextField(blank=True, 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) auto_tagging_rules = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False) search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False) default_mark_unread = models.BooleanField(default=False, null=False)
items_per_page = models.IntegerField(
null=False, default=30, validators=[MinValueValidator(10)]
)
sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
hide_bundles = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
self.custom_css_hash = hashlib.md5(
self.custom_css.encode("utf-8")
).hexdigest()
else:
self.custom_css_hash = ""
super().save(*args, **kwargs)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
@@ -450,16 +517,20 @@ class UserProfileForm(forms.ModelForm):
"default_mark_unread", "default_mark_unread",
"custom_css", "custom_css",
"auto_tagging_rules", "auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
"hide_bundles",
] ]
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs): def create_user_profile(sender, instance, created, **kwargs):
if created: if created:
UserProfile.objects.create(user=instance) UserProfile.objects.create(user=instance)
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs): def save_user_profile(sender, instance, **kwargs):
instance.profile.save() instance.profile.save()
@@ -468,7 +539,7 @@ class Toast(models.Model):
key = models.CharField(max_length=50) key = models.CharField(max_length=50)
message = models.TextField() message = models.TextField()
acknowledged = models.BooleanField(default=False) acknowledged = models.BooleanField(default=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
class FeedToken(models.Model): class FeedToken(models.Model):
@@ -478,7 +549,7 @@ class FeedToken(models.Model):
key = models.CharField(max_length=40, primary_key=True) key = models.CharField(max_length=40, primary_key=True)
user = models.OneToOneField( user = models.OneToOneField(
get_user_model(), User,
related_name="feed_token", related_name="feed_token",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@@ -512,7 +583,7 @@ class GlobalSettings(models.Model):
default=LANDING_PAGE_LOGIN, default=LANDING_PAGE_LOGIN,
) )
guest_profile_user = models.ForeignKey( guest_profile_user = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True User, on_delete=models.SET_NULL, null=True, blank=True
) )
enable_link_prefetch = models.BooleanField(default=False, null=False) enable_link_prefetch = models.BooleanField(default=False, null=False)

View File

@@ -2,16 +2,26 @@ from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.db.models.functions import Lower from django.db.models.functions import Lower
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile from bookmarks.models import (
Bookmark,
BookmarkBundle,
BookmarkSearch,
Tag,
UserProfile,
parse_tag_string,
)
from bookmarks.utils import unique from bookmarks.utils import unique
def query_bookmarks( def query_bookmarks(
user: User, profile: UserProfile, search: BookmarkSearch user: User,
profile: UserProfile,
search: BookmarkSearch,
) -> QuerySet: ) -> QuerySet:
return _base_bookmarks_query(user, profile, search).filter(is_archived=False) return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
return _base_bookmarks_query(user, profile, search).filter(conditions) return _base_bookmarks_query(user, profile, search).filter(conditions)
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
# Search terms
search_terms = parse_query_string(bundle.search)["search_terms"]
for term in search_terms:
conditions = (
Q(title__icontains=term)
| Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(url__icontains=term)
)
query_set = query_set.filter(conditions)
# Any tags - at least one tag must match
any_tags = parse_tag_string(bundle.any_tags, " ")
if len(any_tags) > 0:
tag_conditions = Q()
for tag in any_tags:
tag_conditions |= Q(tags__name__iexact=tag)
query_set = query_set.filter(
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)
# All tags - all tags must match
all_tags = parse_tag_string(bundle.all_tags, " ")
for tag in all_tags:
query_set = query_set.filter(tags__name__iexact=tag)
# Excluded tags - no tags must match
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
if len(exclude_tags) > 0:
tag_conditions = Q()
for tag in exclude_tags:
tag_conditions |= Q(tags__name__iexact=tag)
query_set = query_set.exclude(
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)
return query_set
def _base_bookmarks_query( def _base_bookmarks_query(
user: Optional[User], profile: UserProfile, search: BookmarkSearch user: Optional[User],
profile: UserProfile,
search: BookmarkSearch,
) -> QuerySet: ) -> QuerySet:
query_set = Bookmark.objects query_set = Bookmark.objects
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
if user: if user:
query_set = query_set.filter(owner=user) query_set = query_set.filter(owner=user)
# Filter by modified_since if provided
if search.modified_since:
try:
query_set = query_set.filter(date_modified__gt=search.modified_since)
except ValidationError:
# If the date format is invalid, ignore the filter
pass
# Filter by added_since if provided
if search.added_since:
try:
query_set = query_set.filter(date_added__gt=search.added_since)
except ValidationError:
# If the date format is invalid, ignore the filter
pass
# Split query into search terms and tags # Split query into search terms and tags
query = parse_query_string(search.q) query = parse_query_string(search.q)
@@ -53,8 +122,6 @@ def _base_bookmarks_query(
Q(title__icontains=term) Q(title__icontains=term)
| Q(description__icontains=term) | Q(description__icontains=term)
| Q(notes__icontains=term) | Q(notes__icontains=term)
| Q(website_title__icontains=term)
| Q(website_description__icontains=term)
| Q(url__icontains=term) | Q(url__icontains=term)
) )
@@ -87,13 +154,11 @@ def _base_bookmarks_query(
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED: elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
query_set = query_set.filter(shared=False) query_set = query_set.filter(shared=False)
# Sort by date added # Filter by bundle
if search.sort == BookmarkSearch.SORT_ADDED_ASC: if search.bundle:
query_set = query_set.order_by("date_added") query_set = _filter_bundle(query_set, search.bundle)
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
query_set = query_set.order_by("-date_added")
# Sort by title # Sort
if ( if (
search.sort == BookmarkSearch.SORT_TITLE_ASC search.sort == BookmarkSearch.SORT_TITLE_ASC
or search.sort == BookmarkSearch.SORT_TITLE_DESC or search.sort == BookmarkSearch.SORT_TITLE_DESC
@@ -103,10 +168,6 @@ def _base_bookmarks_query(
query_set = query_set.annotate( query_set = query_set.annotate(
effective_title=Case( effective_title=Case(
When(Q(title__isnull=False) & ~Q(title__exact=""), then=Lower("title")), 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"), default=Lower("url"),
output_field=CharField(), output_field=CharField(),
) )
@@ -124,6 +185,11 @@ def _base_bookmarks_query(
query_set = query_set.order_by(order_field) query_set = query_set.order_by(order_field)
elif search.sort == BookmarkSearch.SORT_TITLE_DESC: elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
query_set = query_set.order_by(order_field).reverse() 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 return query_set

View File

@@ -0,0 +1,177 @@
import gzip
import logging
import os
import shutil
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone, formats
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import singlefile
MAX_ASSET_FILENAME_LENGTH = 192
logger = logging.getLogger(__name__)
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
date_created = timezone.now()
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
date_created=date_created,
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def create_snapshot(asset: BookmarkAsset):
try:
# Create snapshot into temporary file
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
# Store as gzip in asset folder
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(temp_filepath, "rb") as temp_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(temp_file, gz_file)
# Remove temporary file
os.remove(temp_filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
asset.bookmark.latest_snapshot = asset
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save()
except Exception as error:
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
raise error
def upload_snapshot(bookmark: Bookmark, html: bytes):
asset = create_snapshot_asset(bookmark)
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb") as gz_file:
gz_file.write(html)
# Only save the asset if the file was written successfully
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
asset.bookmark.latest_snapshot = asset
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save()
return asset
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
try:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
date_created=timezone.now(),
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_COMPLETE,
gzip=False,
)
name, extension = os.path.splitext(upload_file.name)
# automatically gzip the file if it is not already gzipped
if upload_file.content_type != "application/gzip":
filename = _generate_asset_filename(
asset, name, extension.lstrip(".") + ".gz"
)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb", compresslevel=9) as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.gzip = True
asset.file = filename
asset.file_size = os.path.getsize(filepath)
else:
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save()
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save()
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
return asset
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
raise e
def remove_asset(asset: BookmarkAsset):
# If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot
bookmark = asset.bookmark
if bookmark and bookmark.latest_snapshot == asset:
latest = (
BookmarkAsset.objects.filter(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
.exclude(pk=asset.pk)
.order_by("-date_created")
.first()
)
bookmark.latest_snapshot = latest
asset.delete()
bookmark.date_modified = timezone.now()
bookmark.save()
def _generate_asset_filename(
asset: BookmarkAsset, filename: str, extension: str
) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_filename = "".join(sanitize_char(char) for char in filename)
# Calculate the length of fixed parts of the final filename
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
# Calculate the maximum length for the dynamic part of the filename
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
# Truncate the filename if necessary
sanitized_filename = sanitized_filename[:max_filename_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"

View File

@@ -7,11 +7,22 @@ def get_tags(script: str, url: str):
parsed_url = urlparse(url.lower()) parsed_url = urlparse(url.lower())
result = set() result = set()
for line in script.lower().split("\n"): if not parsed_url.hostname:
if "#" in line: return result
i = line.index("#")
line = line[:i]
for line in script.lower().split("\n"):
line = line.strip()
# Skip empty lines or lines that start with a comment
if not line or line.startswith("#"):
continue
# Remove trailing comment - only if # is preceded by whitespace
comment_match = re.search(r"\s+#", line)
if comment_match:
line = line[: comment_match.start()]
# Ignore lines that don't contain a URL and a tag
parts = line.split() parts = line.split()
if len(parts) < 2: if len(parts) < 2:
continue continue
@@ -33,6 +44,11 @@ def get_tags(script: str, url: str):
): ):
continue continue
if parsed_pattern.fragment and not _fragment_matches(
parsed_pattern.fragment, parsed_url.fragment
):
continue
for tag in parts[1:]: for tag in parts[1:]:
result.add(tag) result.add(tag)
@@ -62,3 +78,7 @@ def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
return False return False
return True return True
def _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:
return actual_fragment.startswith(expected_fragment)

View File

@@ -1,22 +1,23 @@
import logging import logging
import os
from typing import Union from typing import Union
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string from bookmarks.models import Bookmark, User, parse_tag_string
from bookmarks.services import auto_tagging
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services import auto_tagging
from bookmarks.services.tags import get_or_create_tags from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): def create_bookmark(
bookmark: Bookmark,
tag_string: str,
current_user: User,
disable_html_snapshot: bool = False,
):
# If URL is already bookmarked, then update it # If URL is already bookmarked, then update it
existing_bookmark: Bookmark = Bookmark.objects.filter( existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url=bookmark.url owner=current_user, url=bookmark.url
@@ -26,8 +27,6 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
_merge_bookmark_data(bookmark, existing_bookmark) _merge_bookmark_data(bookmark, existing_bookmark)
return update_bookmark(existing_bookmark, tag_string, current_user) return update_bookmark(existing_bookmark, tag_string, current_user)
# Update website info
_update_website_metadata(bookmark)
# Set currently logged in user as owner # Set currently logged in user as owner
bookmark.owner = current_user bookmark.owner = current_user
# Set dates # Set dates
@@ -44,7 +43,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
# Load preview image # Load preview image
tasks.load_preview_image(current_user, bookmark) tasks.load_preview_image(current_user, bookmark)
# Create HTML snapshot # Create HTML snapshot
if current_user.profile.enable_automatic_html_snapshots: if (
current_user.profile.enable_automatic_html_snapshots
and not disable_html_snapshot
):
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
return bookmark return bookmark
@@ -67,13 +69,21 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
if has_url_changed: if has_url_changed:
# Update web archive snapshot, if URL changed # Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True) tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed
_update_website_metadata(bookmark)
bookmark.save()
return bookmark 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): def archive_bookmark(bookmark: Bookmark):
bookmark.is_archived = True bookmark.is_archived = True
bookmark.date_modified = timezone.now() bookmark.date_modified = timezone.now()
@@ -187,44 +197,24 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
) )
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str): def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: User):
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S") sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
return f"{asset.asset_type}_{formatted_datetime}_{filename}" owned_bookmarks = Bookmark.objects.filter(
owner=current_user, id__in=sanitized_bookmark_ids
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
) )
asset.save()
try: for bookmark in owned_bookmarks:
filename = _generate_upload_asset_filename(asset, upload_file.name) tasks.refresh_metadata(bookmark)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) tasks.load_preview_image(current_user, bookmark)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.file_size = upload_file.size
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
return asset def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
owned_bookmarks = Bookmark.objects.filter(
owner=current_user, id__in=sanitized_bookmark_ids
)
tasks.create_html_snapshots(owned_bookmarks)
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
@@ -235,12 +225,6 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.shared = from_bookmark.shared 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): def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string) tag_names = parse_tag_string(tag_string)

View File

@@ -0,0 +1,37 @@
from django.db.models import Max
from bookmarks.models import BookmarkBundle, User
def create_bundle(bundle: BookmarkBundle, current_user: User):
bundle.owner = current_user
if bundle.order is None:
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
Max("order", default=-1)
)
bundle.order = max_order_result["order__max"] + 1
bundle.save()
return bundle
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
user_bundles = list(
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
)
if new_order != user_bundles.index(bundle_to_move):
user_bundles.remove(bundle_to_move)
user_bundles.insert(new_order, bundle_to_move)
for bundle_index, bundle in enumerate(user_bundles):
bundle.order = bundle_index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
def delete_bundle(bundle: BookmarkBundle):
bundle.delete()
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
for index, user_bundle in enumerate(user_bundles):
user_bundle.order = index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])

View File

@@ -35,14 +35,15 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]" desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]"
tag_names = bookmark.tag_names tag_names = bookmark.tag_names
if bookmark.is_archived: if bookmark.is_archived:
tag_names.append("linkding:archived") tag_names.append("linkding:bookmarks.archived")
tags = ",".join(tag_names) tags = ",".join(tag_names)
toread = "1" if bookmark.unread else "0" toread = "1" if bookmark.unread else "0"
private = "0" if bookmark.shared else "1" private = "0" if bookmark.shared else "1"
added = int(bookmark.date_added.timestamp()) added = int(bookmark.date_added.timestamp())
modified = int(bookmark.date_modified.timestamp())
doc.append( 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: if desc:

View File

@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
for netscape_bookmark in netscape_bookmarks: for netscape_bookmark in netscape_bookmarks:
for tag_name in netscape_bookmark.tag_names: for tag_name in netscape_bookmark.tag_names:
# Skip tag names that exceed the maximum allowed length
if len(tag_name) > 64:
logger.warning(
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
)
continue
tag = tag_cache.get(tag_name) tag = tag_cache.get(tag_name)
if not tag: if not tag:
tag = Tag(name=tag_name, owner=user) tag = Tag(name=tag_name, owner=user)
@@ -231,7 +238,10 @@ def _copy_bookmark_data(
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added) bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else: else:
bookmark.date_added = timezone.now() 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 bookmark.unread = netscape_bookmark.to_read
if netscape_bookmark.title: if netscape_bookmark.title:
bookmark.title = netscape_bookmark.title bookmark.title = netscape_bookmark.title

View File

@@ -12,6 +12,7 @@ class NetscapeBookmark:
description: str description: str
notes: str notes: str
date_added: str date_added: str
date_modified: str
tag_names: List[str] tag_names: List[str]
to_read: bool to_read: bool
private: bool private: bool
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
self.bookmark = None self.bookmark = None
self.href = "" self.href = ""
self.add_date = "" self.add_date = ""
self.last_modified = ""
self.tags = "" self.tags = ""
self.title = "" self.title = ""
self.description = "" self.description = ""
@@ -60,9 +62,9 @@ class BookmarkParser(HTMLParser):
def handle_start_a(self, attrs: Dict[str, str]): def handle_start_a(self, attrs: Dict[str, str]):
vars(self).update(attrs) vars(self).update(attrs)
tag_names = parse_tag_string(self.tags) tag_names = parse_tag_string(self.tags)
archived = "linkding:archived" in self.tags archived = "linkding:bookmarks.archived" in self.tags
try: try:
tag_names.remove("linkding:archived") tag_names.remove("linkding:bookmarks.archived")
except ValueError: except ValueError:
pass pass
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
description="", description="",
notes="", notes="",
date_added=self.add_date, date_added=self.add_date,
date_modified=self.last_modified,
tag_names=tag_names, tag_names=tag_names,
to_read=self.toread == "1", to_read=self.toread == "1",
# Mark as private by default, also when attribute is not specified # Mark as private by default, also when attribute is not specified
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
self.bookmark = None self.bookmark = None
self.href = "" self.href = ""
self.add_date = "" self.add_date = ""
self.last_modified = ""
self.tags = "" self.tags = ""
self.title = "" self.title = ""
self.description = "" self.description = ""

View File

@@ -1,8 +1,6 @@
import gzip
import logging import logging
import os import os
import shlex import shlex
import shutil
import signal import signal
import subprocess import subprocess
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
def create_snapshot(url: str, filepath: str): def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH singlefile_path = settings.LD_SINGLEFILE_PATH
# parse options to list of arguments # parse options to list of arguments
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS) ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS) custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
temp_filepath = filepath + ".tmp"
# concat lists # concat lists
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath] args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
try: try:
# Use start_new_session=True to create a new process group # Use start_new_session=True to create a new process group
process = subprocess.Popen(args, start_new_session=True) process = subprocess.Popen(args, start_new_session=True)
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC) process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
# check if the file was created # check if the file was created
if not os.path.exists(temp_filepath): if not os.path.exists(filepath):
raise SingleFileError("Failed to create snapshot") raise SingleFileError("Failed to create snapshot")
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# First try to terminate properly # First try to terminate properly
try: try:

View File

@@ -1,22 +1,20 @@
import functools import functools
import logging import logging
import os
from typing import List from typing import List
import waybackpy import waybackpy
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.utils import timezone, formats from django.utils import timezone
from huey import crontab from huey import crontab
from huey.contrib.djhuey import HUEY as huey from huey.contrib.djhuey import HUEY as huey
from huey.exceptions import TaskLockedException from huey.exceptions import TaskLockedException
from waybackpy.exceptions import WaybackError, TooManyRequestsError from waybackpy.exceptions import WaybackError, TooManyRequestsError
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile, preview_image_loader from bookmarks.services import assets, favicon_loader, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT from bookmarks.services.website_loader import DEFAULT_USER_AGENT, load_website_metadata
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,7 +157,7 @@ def schedule_bookmarks_without_favicons(user: User):
@task() @task()
def _schedule_bookmarks_without_favicons_task(user_id: int): def _schedule_bookmarks_without_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id) user = User.objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user) bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
# TODO: Implement bulk task creation # TODO: Implement bulk task creation
@@ -175,7 +173,7 @@ def schedule_refresh_favicons(user: User):
@task() @task()
def _schedule_refresh_favicons_task(user_id: int): def _schedule_refresh_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id) user = User.objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(owner=user) bookmarks = Bookmark.objects.filter(owner=user)
# TODO: Implement bulk task creation # TODO: Implement bulk task creation
@@ -214,7 +212,7 @@ def schedule_bookmarks_without_previews(user: User):
@task() @task()
def _schedule_bookmarks_without_previews_task(user_id: int): def _schedule_bookmarks_without_previews_task(user_id: int):
user = get_user_model().objects.get(id=user_id) user = User.objects.get(id=user_id)
bookmarks = Bookmark.objects.filter( bookmarks = Bookmark.objects.filter(
Q(preview_image_file__exact=""), Q(preview_image_file__exact=""),
owner=user, owner=user,
@@ -228,6 +226,31 @@ def _schedule_bookmarks_without_previews_task(user_id: int):
logging.exception(exc) logging.exception(exc)
def refresh_metadata(bookmark: Bookmark):
if not settings.LD_DISABLE_BACKGROUND_TASKS:
_refresh_metadata_task(bookmark.id)
@task()
def _refresh_metadata_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
logger.info(f"Refresh metadata for bookmark. url={bookmark.url}")
metadata = load_website_metadata(bookmark.url)
if metadata.title:
bookmark.title = metadata.title
if metadata.description:
bookmark.description = metadata.description
bookmark.date_modified = timezone.now()
bookmark.save()
logger.info(f"Successfully refreshed metadata for bookmark. url={bookmark.url}")
def is_html_snapshot_feature_active() -> bool: def is_html_snapshot_feature_active() -> bool:
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
@@ -236,7 +259,7 @@ def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active(): if not is_html_snapshot_feature_active():
return return
asset = _create_snapshot_asset(bookmark) asset = assets.create_snapshot_asset(bookmark)
asset.save() asset.save()
@@ -246,47 +269,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
assets_to_create = [] assets_to_create = []
for bookmark in bookmark_list: for bookmark in bookmark_list:
asset = _create_snapshot_asset(bookmark) asset = assets.create_snapshot_asset(bookmark)
assets_to_create.append(asset) assets_to_create.append(asset)
BookmarkAsset.objects.bulk_create(assets_to_create) BookmarkAsset.objects.bulk_create(assets_to_create)
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
content_type="text/html",
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
# Calculate the length of the non-URL parts of the filename
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
# Calculate the maximum length for the URL part
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
# Truncate the URL if necessary
sanitized_url = sanitized_url[:max_url_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
# singe-file does not support running multiple instances in parallel, so we can # singe-file does not support running multiple instances in parallel, so we can
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic # not queue up multiple snapshot tasks at once. Instead, schedule a periodic
# task that grabs a number of pending assets and creates snapshots for them in # task that grabs a number of pending assets and creates snapshots for them in
@@ -313,13 +301,8 @@ def _create_html_snapshot_task(asset_id: int):
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}") logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
try: try:
filename = _generate_snapshot_filename(asset) assets.create_snapshot(asset)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
singlefile.create_snapshot(asset.bookmark.url, filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
logger.info( logger.info(
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}" f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
) )
@@ -328,8 +311,6 @@ def _create_html_snapshot_task(asset_id: int):
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}", f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
exc_info=error, exc_info=error,
) )
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
def create_missing_html_snapshots(user: User) -> int: def create_missing_html_snapshots(user: User) -> int:

View File

@@ -14,8 +14,8 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class WebsiteMetadata: class WebsiteMetadata:
url: str url: str
title: str title: str | None
description: str description: str | None
preview_image: str | None preview_image: str | None
def to_dict(self): def to_dict(self):
@@ -27,10 +27,20 @@ class WebsiteMetadata:
} }
def load_website_metadata(url: str, ignore_cache: bool = False):
if ignore_cache:
return _load_website_metadata(url)
return _load_website_metadata_cached(url)
# Caching metadata avoids scraping again when saving bookmarks, in case the # Caching metadata avoids scraping again when saving bookmarks, in case the
# metadata was already scraped to show preview values in the bookmark form # metadata was already scraped to show preview values in the bookmark form
@lru_cache(maxsize=10) @lru_cache(maxsize=10)
def load_website_metadata(url: str): def _load_website_metadata_cached(url: str):
return _load_website_metadata(url)
def _load_website_metadata(url: str):
title = None title = None
description = None description = None
preview_image = None preview_image = None
@@ -43,7 +53,8 @@ def load_website_metadata(url: str):
start = timezone.now() start = timezone.now()
soup = BeautifulSoup(page_text, "html.parser") 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_tag = soup.find("meta", attrs={"name": "description"})
description = ( description = (
description_tag["content"].strip() description_tag["content"].strip()

View File

@@ -58,7 +58,7 @@ MIDDLEWARE = [
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
] ]
ROOT_URLCONF = "siteroot.urls" ROOT_URLCONF = "bookmarks.urls"
TEMPLATES = [ TEMPLATES = [
{ {
@@ -80,7 +80,7 @@ TEMPLATES = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
WSGI_APPLICATION = "siteroot.wsgi.application" WSGI_APPLICATION = "bookmarks.wsgi.application"
# Password validation # Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
@@ -128,18 +126,10 @@ STATIC_URL = "/" + LD_CONTEXT_PATH + "static/"
# Collect static files in static folder # Collect static files in static folder
STATIC_ROOT = os.path.join(BASE_DIR, "static") STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATICFILES_DIRS = [
# Resolve theme files from style source folder
os.path.join(BASE_DIR, "bookmarks", "styles"),
# Resolve downloaded files in dev environment
os.path.join(BASE_DIR, "data", "favicons"),
os.path.join(BASE_DIR, "data", "previews"),
]
# REST framework # REST framework
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication", "bookmarks.api.auth.LinkdingTokenAuthentication",
"rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.SessionAuthentication",
], ],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
@@ -154,6 +144,7 @@ ALLOW_REGISTRATION = False
LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in ( LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
True, True,
"True", "True",
"true",
"1", "1",
) )
@@ -161,6 +152,7 @@ LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in ( LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in (
True, True,
"True", "True",
"true",
"1", "1",
) )
@@ -187,7 +179,7 @@ HUEY = {
# Enable OICD support if configured # Enable OICD support if configured
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1") LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "true", "1")
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
@@ -202,11 +194,18 @@ if LD_ENABLE_OIDC:
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID") OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET") OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256") OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1") OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1") OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "true", "1")
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "true", "1")
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
# Enable authentication proxy support if configured # Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1") LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (
True,
"True",
"true",
"1",
)
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv( LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
"LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER" "LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER"
) )
@@ -273,6 +272,7 @@ LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")
LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in ( LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
True, True,
"True", "True",
"true",
"1", "1",
) )
@@ -294,6 +294,13 @@ LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in ( LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
True, True,
"True", "True",
"true",
"1",
)
LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
True,
"True",
"true",
"1", "1",
) )
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file") LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
@@ -304,7 +311,7 @@ LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
'--browser-arg="--headless=new"', '--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"', '--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"', '--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"', '--browser-arg="--load-extension=uBOLite.chromium.mv3"',
] ]
), ),
) )

View File

@@ -20,6 +20,14 @@ INTERNAL_IPS = [
# Allow access through ngrok # Allow access through ngrok
CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"] CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"]
STATICFILES_DIRS = [
# Resolve theme files from style source folder
os.path.join(BASE_DIR, "bookmarks", "styles"),
# Resolve downloaded files in dev environment
os.path.join(BASE_DIR, "data", "favicons"),
os.path.join(BASE_DIR, "data", "previews"),
]
# Enable debug logging # Enable debug logging
LOGGING = { LOGGING = {
"version": 1, "version": 1,

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,5 +1,13 @@
/* Common styles */ /* Common styles */
.bookmark-details { .bookmark-details {
.title {
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
}
& .weblinks { & .weblinks {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -12,7 +20,8 @@
gap: var(--unit-2); gap: var(--unit-2);
} }
& a.weblink img, & a.weblink svg { & a.weblink img,
& a.weblink svg {
flex: 0 0 auto; flex: 0 0 auto;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -27,7 +36,7 @@
} }
& .preview-image { & .preview-image {
margin: var(--unit-4 0); margin: var(--unit-4) 0;
img { img {
max-width: 100%; max-width: 100%;
@@ -35,57 +44,22 @@
} }
} }
& dl { & .sections section {
margin-bottom: 0; margin-top: var(--unit-4);
}
& .sections h3 {
margin-bottom: var(--unit-2);
font-size: var(--font-size);
font-weight: bold;
} }
& .assets { & .assets {
margin-top: var(--unit-2); margin-top: var(--unit-2);
& .asset { & .filesize {
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); 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 { & .assets-actions {
@@ -110,7 +84,8 @@
gap: var(--unit-2); gap: var(--unit-2);
} }
& .status .form-group, .status .form-switch { & .status .form-group,
.status .form-switch {
margin: 0; margin: 0;
} }

View File

@@ -1,37 +1,37 @@
.bookmarks-form-page { .bookmarks-form-page {
section { main {
max-width: 550px; max-width: 550px;
margin: 0 auto; margin: 0 auto;
} }
} }
.bookmarks-form { .bookmarks-form {
& .btn.btn-link.form-icon { & .has-icon-right > input,
padding: 0; & .has-icon-right > textarea {
width: 20px;
height: 20px;
visibility: hidden;
--btn-icon-color: var(--tertiary-text-color);
& > svg {
width: 20px;
height: 20px;
}
}
& .has-icon-right > input, & .has-icon-right > textarea {
padding-right: 30px; 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 { & .form-icon.loading {
visibility: hidden; visibility: hidden;
} }
& .form-group .suffix-button {
padding: 0;
border: none;
height: auto;
font-size: var(--font-size-sm);
}
& .form-group .clear-button,
& .form-group #refresh-button {
display: none;
}
& .form-group input.modified,
& .form-group textarea.modified {
background: var(--primary-color-shade);
}
& .form-input-hint.bookmark-exists { & .form-input-hint.bookmark-exists {
display: none; display: none;
color: var(--warning-color); color: var(--warning-color);
@@ -45,4 +45,4 @@
& details.notes textarea { & details.notes textarea {
box-sizing: border-box; box-sizing: border-box;
} }
} }

View File

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

View File

@@ -0,0 +1,34 @@
.bundles-page {
h1 {
font-size: var(--font-size-lg);
margin-bottom: var(--unit-6);
}
.item-list {
.list-item .list-item-icon {
cursor: grab;
}
.list-item.drag-start {
--secondary-border-color: transparent;
}
.list-item.dragging > * {
visibility: hidden;
}
}
}
.bundles-editor-page {
&.grid {
gap: var(--unit-9);
}
.form-footer {
position: sticky;
bottom: 0;
border-top: solid 1px var(--secondary-border-color);
background: var(--body-color);
padding: var(--unit-3) 0;
}
}

View File

@@ -1,65 +1,119 @@
/* Shared components */ /* Shared components */
/* Content area component */ /* Section header component */
section.content-area { .section-header {
h2 { border-bottom: solid 1px var(--secondary-border-color);
font-size: var(--font-size-lg); display: flex;
} flex-wrap: wrap;
column-gap: var(--unit-5);
padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4);
.content-area-header { h1,
border-bottom: solid 1px var(--secondary-border-color); h2,
display: flex; h3 {
flex-wrap: wrap; font-size: var(--font-size-lg);
column-gap: var(--unit-5); flex: 0 0 auto;
padding-bottom: var(--unit-2); line-height: var(--unit-9);
margin-bottom: var(--unit-4); margin: 0;
}
h2 { .header-controls {
flex: 0 0 auto; flex: 1 1 0;
line-height: var(--unit-9); display: flex;
margin: 0; }
}
.header-controls {
flex: 1 1 0;
display: flex;
}
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
section.content-area .content-area-header { .section-header:not(.no-wrap) {
flex-direction: column; flex-direction: column;
} }
} }
/* Confirm button component */ /* Confirm button component */
span.confirmation { span.confirmation {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: var(--unit-1); gap: var(--unit-1);
color: var(--error-color) !important;
svg {
align-self: center;
}
.btn.btn-link {
color: var(--error-color) !important; color: var(--error-color) !important;
svg { &:hover {
align-self: center; text-decoration: underline;
}
.btn.btn-link {
color: var(--error-color) !important;
&:hover {
text-decoration: underline;
}
} }
}
} }
/* Divider */ /* Divider */
.divider { .divider {
border-bottom: solid 1px var(--secondary-border-color); border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-5) 0; margin: var(--unit-5) 0;
} }
/* Turbo progress bar */ /* Turbo progress bar */
.turbo-progress-bar { .turbo-progress-bar {
background-color: var(--primary-color); background-color: var(--primary-color);
}
/* Messages */
.message-list {
margin: var(--unit-4) 0;
.toast {
margin-bottom: var(--unit-2);
}
.toast a.btn-clear:visited {
color: currentColor;
}
}
/* Item list */
.item-list {
& .list-item {
display: flex;
align-items: center;
gap: var(--unit-2);
padding: var(--unit-2) 0;
border-top: var(--unit-o) solid var(--secondary-border-color);
}
& .list-item:last-child {
border-bottom: var(--unit-o) solid var(--secondary-border-color);
}
& .list-item-icon {
display: flex;
align-items: center;
justify-content: center;
}
& .list-item-text {
flex: 1 1 0;
gap: var(--unit-2);
min-width: 0;
display: flex;
}
& .list-item-text .truncate {
flex-shrink: 1;
}
& .list-item-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
} }

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
.settings-page { .settings-page {
section.content-area { h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--unit-6);
}
section {
margin-bottom: var(--unit-10); margin-bottom: var(--unit-10);
h2 { h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--unit-3); margin-bottom: var(--unit-3);
} }
} }
@@ -12,7 +18,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
.input-group > input[type=submit] { .input-group > input[type="submit"] {
height: auto; height: auto;
} }

View File

@@ -1,143 +1,150 @@
@import "theme-light.css"; @import "theme-light.css";
:root { :root {
/* Color palette */ /* Color palette */
--contrast-5: hsla(241, 65%, 85%, 0.06); --contrast-5: hsla(241, 65%, 85%, 0.06);
--contrast-10: hsla(241, 60%, 80%, 0.14); --contrast-10: hsla(241, 60%, 80%, 0.14);
--contrast-20: hsla(241, 64%, 82%, 0.23); --contrast-20: hsla(241, 64%, 82%, 0.23);
--contrast-30: hsla(241, 69%, 84%, 0.32); --contrast-30: hsla(241, 69%, 84%, 0.32);
--contrast-40: hsla(241, 73%, 86%, 0.41); --contrast-40: hsla(241, 73%, 86%, 0.41);
--contrast-50: hsla(241, 78%, 88%, 0.5); --contrast-50: hsla(241, 78%, 88%, 0.5);
--contrast-60: hsla(241, 82%, 90%, 0.58); --contrast-60: hsla(241, 82%, 90%, 0.58);
--contrast-70: hsla(241, 87%, 92%, 0.69); --contrast-70: hsla(241, 87%, 92%, 0.69);
--contrast-80: hsla(241, 91%, 94%, 0.8); --contrast-80: hsla(241, 91%, 94%, 0.8);
--contrast-90: hsla(241, 96%, 96%, 0.9); --contrast-90: hsla(241, 96%, 96%, 0.9);
--primary-color: hsl(241, 75%, 64%); --primary-color: hsl(241, 75%, 64%);
--primary-color-highlight: hsl(241, 75%, 68%); --primary-color-highlight: hsl(241, 75%, 68%);
--primary-color-shade: hsl(241, 75%, 64%, 0.42); --primary-color-shade: hsl(241, 75%, 64%, 0.42);
--alternative-color: hsl(179, 50%, 58%); --alternative-color: hsl(179, 50%, 58%);
--alternative-color-dark: hsl(179, 80%, 75%); --alternative-color-dark: hsl(179, 80%, 75%);
--success-color: hsl(142, 76%, 36%); --success-color: hsl(142, 76%, 36%);
--success-color-highlight: hsl(142, 76%, 40%); --success-color-highlight: hsl(142, 76%, 40%);
--success-color-shade: hsla(142, 76%, 36%, 0.1); --success-color-shade: hsla(142, 76%, 36%, 0.1);
--warning-color: hsl(38, 92%, 50%); --warning-color: hsl(38, 92%, 50%);
--warning-color-highlight: hsl(38, 92%, 55%); --warning-color-highlight: hsl(38, 92%, 55%);
--warning-color-shade: hsla(38, 92%, 50%, 0.1); --warning-color-shade: hsla(38, 92%, 50%, 0.1);
--error-color: hsl(0, 80%, 60%); --error-color: hsl(0, 80%, 60%);
--error-color-highlight: hsl(0, 72%, 60%); --error-color-highlight: hsl(0, 72%, 60%);
--error-color-shade: hsla(0, 72%, 51%, 0.1); --error-color-shade: hsla(0, 72%, 51%, 0.1);
/* Core colors */ /* Core colors */
--text-color: var(--gray-300); --text-color: var(--gray-300);
--secondary-text-color: var(--gray-400); --secondary-text-color: var(--gray-400);
--tertiary-text-color: var(--gray-500); --tertiary-text-color: var(--gray-500);
--contrast-text-color: #fff; --contrast-text-color: #fff;
--primary-text-color: hsl(241, 82%, 82%); --primary-text-color: hsl(241, 82%, 82%);
--link-color: var(--primary-text-color); --link-color: var(--primary-text-color);
--secondary-link-color: hsla(241, 82%, 82%, 0.8); --secondary-link-color: hsla(241, 82%, 82%, 0.8);
--icon-color: var(--text-color); --icon-color: var(--text-color);
--border-color: var(--contrast-30); --border-color: var(--contrast-30);
--secondary-border-color: var(--contrast-20); --secondary-border-color: var(--contrast-20);
--body-color: hsl(241, 15%, 14%); --body-color: hsl(241, 15%, 14%);
--body-color-contrast: var(--contrast-10); --body-color-contrast: var(--contrast-10);
/* Focus */ /* Focus */
--focus-outline: 2px solid hsl(241, 100%, 78%); --focus-outline: 2px solid hsl(241, 100%, 78%);
--focus-outline-offset: 2px; --focus-outline-offset: 2px;
/* Shadows */ /* Shadows */
--box-shadow-xs: none; --box-shadow-xs: none;
--box-shadow: none; --box-shadow: none;
--box-shadow-lg: none; --box-shadow-lg: none;
} }
:root { :root {
--input-bg-color: var(--contrast-5); --input-bg-color: var(--contrast-5);
--input-disabled-bg-color: var(--contrast-30); --input-disabled-bg-color: var(--contrast-30);
--input-text-color: var(--text-color); --input-text-color: var(--text-color);
--input-hint-color: var(--secondary-text-color); --input-hint-color: var(--secondary-text-color);
--input-border-color: var(--border-color); --input-border-color: var(--border-color);
--input-placeholder-color: var(--tertiary-text-color); --input-placeholder-color: var(--tertiary-text-color);
--input-box-shadow: var(--box-shadow-xs); --input-box-shadow: var(--box-shadow-xs);
--checkbox-bg-color: var(--contrast-10); --checkbox-bg-color: var(--contrast-10);
--checkbox-checked-bg-color: var(--primary-color); --checkbox-checked-bg-color: var(--primary-color);
--checkbox-disabled-bg-color: var(--contrast-30); --checkbox-disabled-bg-color: var(--contrast-30);
--checkbox-border-color: var(--border-color); --checkbox-border-color: var(--border-color);
--checkbox-icon-color: #fff; --checkbox-icon-color: #fff;
--switch-bg-color: var(--contrast-10); --switch-bg-color: var(--contrast-10);
--switch-border-color: var(--border-color); --switch-border-color: var(--border-color);
--switch-toggle-color: var(--text-color); --switch-toggle-color: var(--text-color);
} }
:root { :root {
--btn-bg-color: var(--contrast-5); --btn-bg-color: var(--contrast-5);
--btn-hover-bg-color: var(--contrast-20); --btn-hover-bg-color: var(--contrast-20);
--btn-border-color: var(--border-color); --btn-border-color: var(--border-color);
--btn-text-color: var(--text-color); --btn-text-color: var(--text-color);
--btn-icon-color: var(--icon-color); --btn-icon-color: var(--icon-color);
--btn-font-weight: 400; --btn-font-weight: 400;
--btn-box-shadow: var(--box-shadow-xs); --btn-box-shadow: var(--box-shadow-xs);
--btn-primary-bg-color: var(--primary-color); --btn-primary-bg-color: var(--primary-color);
--btn-primary-hover-bg-color: var(--primary-color-highlight); --btn-primary-hover-bg-color: var(--primary-color-highlight);
--btn-primary-text-color: var(--contrast-text-color); --btn-primary-text-color: var(--contrast-text-color);
--btn-success-bg-color: var(--success-color); --btn-success-bg-color: var(--success-color);
--btn-success-hover-bg-color: var(--success-color-highlight); --btn-success-hover-bg-color: var(--success-color-highlight);
--btn-success-text-color: var(--contrast-text-color); --btn-success-text-color: var(--contrast-text-color);
--btn-error-bg-color: var(--error-color); --btn-error-bg-color: var(--error-color);
--btn-error-hover-bg-color: var(--error-color-highlight); --btn-error-hover-bg-color: var(--error-color-highlight);
--btn-error-text-color: var(--contrast-text-color); --btn-error-text-color: var(--contrast-text-color);
--btn-link-text-color: var(--link-color); --btn-link-text-color: var(--link-color);
--btn-link-hover-text-color: var(--link-color); --btn-link-hover-text-color: var(--link-color);
} }
:root { :root {
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55); --modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
--modal-container-bg-color: hsl(241, 20%, 20%); --modal-container-bg-color: hsl(241, 20%, 20%);
--modal-container-border-color: var(--contrast-30); --modal-container-border-color: var(--contrast-30);
--modal-border-radius: var(--border-radius-lg); --modal-border-radius: var(--border-radius-lg);
--modal-box-shadow: none; --modal-box-shadow: none;
} }
:root { :root {
--menu-bg-color: hsl(241, 20%, 20%); --menu-bg-color: hsl(241, 20%, 20%);
--menu-border-color: var(--contrast-30); --menu-border-color: var(--contrast-30);
--menu-border-radius: var(--border-radius); --menu-border-radius: var(--border-radius);
--menu-box-shadow: none; --menu-box-shadow: none;
--menu-item-color: var(--text-color); --menu-item-color: var(--text-color);
--menu-item-hover-color: var(--text-color); --menu-item-hover-color: var(--text-color);
--menu-item-bg-color: transparent; --menu-item-bg-color: transparent;
--menu-item-hover-bg-color: var(--contrast-20); --menu-item-hover-bg-color: var(--contrast-20);
} }
:root { :root {
--tab-color: var(--text-color); --tab-color: var(--text-color);
--tab-hover-color: var(--primary-text-color); --tab-hover-color: var(--primary-text-color);
--tab-active-color: var(--primary-text-color); --tab-active-color: var(--primary-text-color);
--tab-highlight-color: var(--primary-text-color); --tab-highlight-color: var(--primary-text-color);
} }
:root { :root {
--bookmark-title-color: var(--primary-text-color); --bookmark-title-color: var(--primary-text-color);
--bookmark-title-weight: 500; --bookmark-title-weight: 500;
--bookmark-description-color: var(--text-color); --bookmark-description-color: var(--text-color);
--bookmark-description-weight: 400; --bookmark-description-weight: 400;
--bookmark-actions-color: var(--secondary-text-color); --bookmark-actions-color: var(--secondary-text-color);
--bookmark-actions-hover-color: var(--text-color); --bookmark-actions-hover-color: var(--text-color);
--bookmark-actions-weight: 400; --bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--contrast-5); --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

@@ -28,3 +28,4 @@
@import "markdown.css"; @import "markdown.css";
@import "reader-mode.css"; @import "reader-mode.css";
@import "settings.css"; @import "settings.css";
@import "bundles.css";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,17 +4,17 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1"> <div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <main class="main col-2" aria-labelledby="main-heading">
<div class="content-area-header mb-0"> <div class="section-header mb-0">
<h2>Archived bookmarks</h2> <h1 id="main-heading">Archived bookmarks</h1>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %} {% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</button>
</div> </div>
</div> </div>
@@ -28,23 +28,21 @@
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
</section> </main>
{# Tag cloud #} {# Filters #}
<section class="content-area col-1 hide-md"> <div class="side-panel col-1 hide-md">
<div class="content-area-header"> {% include 'bookmarks/bundle_section.html' %}
<h2>Tags</h2> {% include 'bookmarks/tag_section.html' %}
</div> </div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -5,151 +5,166 @@
{% if bookmark_list.is_empty %} {% if bookmark_list.is_empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}" <section aria-label="Bookmark list">
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};" <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> role="list" tabindex="-1"
{% for bookmark_item in bookmark_list.items %} style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}> data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
<div class="content"> {% for bookmark_item in bookmark_list.items %}
<div class="title"> <li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
<label class="form-checkbox bulk-edit-checkbox"> {% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}"> <div class="content">
<i class="form-icon"></i> <div class="title">
</label> <label class="form-checkbox bulk-edit-checkbox">
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %} <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt=""> <i class="form-icon"></i>
{% endif %} </label>
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"> {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<span>{{ bookmark_item.title }}</span> <img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
</a> {% endif %}
</div> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
{% if bookmark_list.show_url %} <span>{{ bookmark_item.title }}</span>
<div class="url-path truncate">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display">
{{ bookmark_item.url }}
</a> </a>
</div> </div>
{% endif %} {% if bookmark_list.show_url %}
{% if bookmark_list.description_display == 'inline' %} <div class="url-path truncate">
<div class="description inline truncate"> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
{% if bookmark_item.tag_names %} class="url-display">
<span class="tags"> {{ bookmark_item.url }}
</a>
</div>
{% endif %}
{% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate">
{% if bookmark_item.tag_names %}
<span class="tags">
{% for tag_name in bookmark_item.tag_names %} {% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% else %}
{% if bookmark_item.description %}
<div class="description separate">{{ bookmark_item.description }}</div>
{% endif %}
{% if bookmark_item.tag_names %}
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% if bookmark_item.notes %}
<div class="notes">
<div class="markdown">{% markdown bookmark_item.notes %}</div>
</div>
{% endif %}
<div class="actions">
{% if bookmark_item.display_date %}
{% if bookmark_item.web_archive_snapshot_url %}
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{{ bookmark_item.display_date }}
</a>
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %} {% endif %}
{% endif %} {% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_list.show_remove_action %} {% if bookmark_item.description %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}" <span>{{ bookmark_item.description }}</span>
class="btn btn-link btn-sm">Remove {% endif %}
</button> </div>
{% endif %}
{% else %} {% else %}
{# Shared bookmark actions #} {% if bookmark_item.description %}
<span>Shared by <div class="description separate">{{ bookmark_item.description }}</div>
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a> {% endif %}
</span> {% if bookmark_item.tag_names %}
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</div>
{% endif %}
{% endif %} {% endif %}
{% if bookmark_item.has_extra_actions %} {% if bookmark_item.notes %}
<div class="extra-actions"> <div class="notes">
<span class="hide-sm">|</span> <div class="markdown">{% markdown bookmark_item.notes %}</div>
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
{% endif %}
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div> </div>
{% endif %} {% endif %}
<div class="actions">
{% if bookmark_item.display_date %}
{% if bookmark_item.snapshot_url %}
<a href="{{ bookmark_item.snapshot_url }}"
title="{{ bookmark_item.snapshot_title }}"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{{ bookmark_item.display_date }}
</a>
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
{% if not bookmark_list.is_preview %}
<span>|</span>
{% endif %}
{% endif %}
{% if not bookmark_list.is_preview %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span>
{% endif %}
{% if bookmark_item.has_extra_actions %}
<div class="extra-actions">
<span class="hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
{% endif %}
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div> </div>
</div> {% if bookmark_list.show_preview_images %}
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %} {% if bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/> <img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
{% endif %} {% else %}
</li> <div class="preview-image placeholder">
{% endfor %} <div class="img"/>
</ul> </div>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
<div class="bookmark-pagination"> <div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
{% pagination bookmark_list.bookmarks_page %} {% pagination bookmark_list.bookmarks_page %}
</div> </div>
</section>
{% endif %} {% endif %}

View File

@@ -22,6 +22,10 @@
<option value="bulk_share">Share</option> <option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
{% endif %} {% endif %}
<option value="bulk_refresh">Refresh from website</option>
{% if bookmark_list.snapshot_feature_enabled %}
<option value="bulk_snapshot">Create HTML snapshot</option>
{% endif %}
</select> </select>
<div class="tag-autocomplete d-none" ld-tag-autocomplete> <div class="tag-autocomplete d-none" ld-tag-autocomplete>
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small"> <input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">

View File

@@ -0,0 +1,36 @@
{% if not request.user_profile.hide_bundles %}
<section aria-labelledby="bundles-heading">
<div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2>
<div ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn dropdown-toggle" aria-label="Bundles menu">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 6l16 0"/>
<path d="M4 12l16 0"/>
<path d="M4 18l16 0"/>
</svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
</li>
{% if bookmark_list.search.q %}
<li class="menu-item">
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
bundle from search</a>
</li>
{% endif %}
</ul>
</div>
</div>
<ul class="bundle-menu">
{% for bundle in bundles.bundles %}
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -1,12 +1,12 @@
<div> <div>
{% if details.assets %} {% if details.assets %}
<div class="assets"> <div class="item-list assets">
{% for asset in details.assets %} {% for asset in details.assets %}
<div class="asset" data-asset-id="{{ asset.id }}"> <div class="list-item" data-asset-id="{{ asset.id }}">
<div class="asset-icon {{ asset.icon_classes }}"> <div class="list-item-icon {{ asset.icon_classes }}">
{% include 'bookmarks/details/asset_icon.html' %} {% include 'bookmarks/details/asset_icon.html' %}
</div> </div>
<div class="asset-text {{ asset.text_classes }}"> <div class="list-item-text {{ asset.text_classes }}">
<span class="truncate"> <span class="truncate">
{{ asset.display_name }} {{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %} {% if asset.status == 'pending' %}(queued){% endif %}
@@ -16,9 +16,9 @@
<span class="filesize">{{ asset.file_size|filesizeformat }}</span> <span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %} {% endif %}
</div> </div>
<div class="asset-actions"> <div class="list-item-actions">
{% if asset.file %} {% if asset.file %}
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a> <a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
{% endif %} {% endif %}
{% if details.is_editable %} {% if details.is_editable %}
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link"> <button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
@@ -33,12 +33,16 @@
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm" {% if details.snapshots_enabled %}
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot <button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
</button> {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit" </button>
class="btn btn-sm">Upload file {% endif %}
</button> {% if details.uploads_enabled %}
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
{% endif %}
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide"> <input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div> </div>
{% endif %} {% endif %}

View File

@@ -14,7 +14,7 @@
<span>{{ details.bookmark.url }}</span> <span>{{ details.bookmark.url }}</span>
</a> </a>
{% if details.latest_snapshot %} {% if details.latest_snapshot %}
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}" <a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %} {% if details.show_link_icons %}
<svg class="favicon" xmlns="http://www.w3.org/2000/svg"> <svg class="favicon" xmlns="http://www.w3.org/2000/svg">
@@ -40,14 +40,14 @@
</div> </div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %} {% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image"> <div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/> <img src="{% static details.bookmark.preview_image_file %}" alt=""/>
</div> </div>
{% endif %} {% endif %}
<dl class="grid columns-2 columns-sm-1 gap-0"> <div class="sections grid columns-2 columns-sm-1 gap-0">
{% if details.is_editable %} {% if details.is_editable %}
<div class="status col-2"> <section class="status col-2">
<dt>Status</dt> <h3>Status</h3>
<dd class="d-flex" style="gap: .8rem"> <div class="d-flex" style="gap: .8rem">
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived" <input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,42 @@
</label> </label>
</div> </div>
{% endif %} {% endif %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
{% if details.show_files %} <section class="files col-2">
<div class="files col-2"> <h3>Files</h3>
<dt>Files</dt> <div>
<dd> {% include 'bookmarks/details/assets.html' %}
{% include 'bookmarks/details/assets.html' %}
</dd>
</div> </div>
{% endif %} </section>
{% if details.bookmark.tag_names %} {% if details.bookmark.tag_names %}
<div class="tags col-1"> <section class="tags col-1">
<dt>Tags</dt> <h3 id="details-modal-tags-title">Tags</h3>
<dd> <div>
{% for tag_name in details.bookmark.tag_names %} {% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
<div class="date-added col-1"> <section class="date-added col-1">
<dt>Date added</dt> <h3>Date added</h3>
<dd> <div>
<span>{{ details.bookmark.date_added }}</span> <span>{{ details.bookmark.date_added }}</span>
</dd>
</div>
{% if details.bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ details.bookmark.resolved_description }}</dd>
</div> </div>
</section>
{% if details.bookmark.resolved_description %}
<section class="description col-2">
<h3>Description</h3>
<div>{{ details.bookmark.resolved_description }}</div>
</section>
{% endif %} {% endif %}
{% if details.bookmark.notes %} {% if details.bookmark.notes %}
<div class="notes col-2"> <section class="notes col-2">
<dt>Notes</dt> <h3>Notes</h3>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd> <div class="markdown">{% markdown details.bookmark.notes %}</div>
</div> </section>
{% endif %} {% endif %}
</dl> </div>
</form> </form>

View File

@@ -1,21 +1,17 @@
<div class="modal active bookmark-details" <div class="modal active bookmark-details" ld-details-modal
ld-details-modal> data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
<a href="{{ details.close_url }}" data-turbo-frame="details-modal"> <div class="modal-overlay"></div>
<div class="modal-overlay" aria-label="Close"></div> <div class="modal-container" role="dialog" aria-modal="true">
</a>
<div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2> <h2 class="title">{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal"> <button class="close" aria-label="Close dialog">
<button class="close"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
<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">
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M18 6l-12 12"></path>
<path d="M18 6l-12 12"></path> <path d="M6 6l12 12"></path>
<path d="M6 6l12 12"></path> </svg>
</svg> </button>
</button>
</a>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="content"> <div class="content">
@@ -28,7 +24,7 @@
<div class="actions"> <div class="actions">
<div class="left-actions"> <div class="left-actions">
<a class="btn btn-wide" <a class="btn btn-wide"
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a> href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
</div> </div>
<div class="right-actions"> <div class="right-actions">
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace"> <form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">

View File

@@ -1,16 +1,21 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load bookmarks %}
{% block head %}
{% with page_title="Edit bookmark - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
<section class="content-area"> <main aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Edit bookmark</h2> <h1 id="main-heading">Edit bookmark</h1>
</div> </div>
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
novalidate> novalidate>
{% bookmark_form form return_url bookmark_id %} {% include 'bookmarks/form.html' %}
</form> </form>
</section> </main>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
<div class="empty"> <div class="empty">
<p class="empty-title h5">You have no bookmarks yet</p> <p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle"> <p class="empty-subtitle">
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks, You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the <a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a <a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>. href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
</p> </p>
</div> </div>

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