mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-27 12:26:46 +02:00
Compare commits
45 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
785fe32aaa | ||
![]() |
5559ad0070 | ||
![]() |
76c65566cf | ||
![]() |
c929e8f11c | ||
![]() |
3ae9cf0420 | ||
![]() |
b736464f3f | ||
![]() |
7572aa5bc9 | ||
![]() |
cb0301fd9e | ||
![]() |
b30486317d | ||
![]() |
1c6e5902db | ||
![]() |
20fe88dd57 | ||
![]() |
aad62f61c9 | ||
![]() |
79bf4b38c6 | ||
![]() |
5eadb3ede3 | ||
![]() |
36749c398b | ||
![]() |
190b5aeeca | ||
![]() |
1122d18e18 | ||
![]() |
0fe6304328 | ||
![]() |
7d4e65976f | ||
![]() |
749bc1ef63 | ||
![]() |
36a84276a2 | ||
![]() |
b72697b819 | ||
![]() |
d9362c9b9c | ||
![]() |
b0610db406 | ||
![]() |
af16a9e727 | ||
![]() |
d898c1be4d | ||
![]() |
0282220307 | ||
![]() |
bb243b382d | ||
![]() |
fbc97a3841 | ||
![]() |
380f5ed19c | ||
![]() |
b28352fb28 | ||
![]() |
695b0dc300 | ||
![]() |
fe40139838 | ||
![]() |
44b49a4cfe | ||
![]() |
469883a674 | ||
![]() |
fa5f78cf71 | ||
![]() |
e03f536925 | ||
![]() |
a92a35cfb8 | ||
![]() |
ff334e0888 | ||
![]() |
0f9ba57fef | ||
![]() |
b4376a9ff1 | ||
![]() |
87cd4061cb | ||
![]() |
e2415f652b | ||
![]() |
9cf5eb5ec0 | ||
![]() |
023a213ba6 |
@@ -10,6 +10,7 @@
|
|||||||
!/manage.py
|
!/manage.py
|
||||||
!/package.json
|
!/package.json
|
||||||
!/package-lock.json
|
!/package-lock.json
|
||||||
|
!/postcss.config.js
|
||||||
!/requirements.dev.txt
|
!/requirements.dev.txt
|
||||||
!/requirements.txt
|
!/requirements.txt
|
||||||
!/rollup.config.mjs
|
!/rollup.config.mjs
|
||||||
|
3
.github/workflows/main.yaml
vendored
3
.github/workflows/main.yaml
vendored
@@ -53,7 +53,6 @@ jobs:
|
|||||||
- name: Run build
|
- name: Run build
|
||||||
run: |
|
run: |
|
||||||
npm run build
|
npm run build
|
||||||
python manage.py compilescss
|
python manage.py collectstatic
|
||||||
python manage.py collectstatic --ignore=*.scss
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -183,7 +183,7 @@ typings/
|
|||||||
### Custom
|
### Custom
|
||||||
# Rollup compilation output
|
# Rollup compilation output
|
||||||
/bookmarks/static/bundle.js*
|
/bookmarks/static/bundle.js*
|
||||||
# SASS compilation output
|
# CSS compilation output
|
||||||
/bookmarks/static/theme-*.css*
|
/bookmarks/static/theme-*.css*
|
||||||
# Collected static files for deployment
|
# Collected static files for deployment
|
||||||
/static
|
/static
|
||||||
@@ -194,3 +194,5 @@ typings/
|
|||||||
# ublock + chromium
|
# ublock + chromium
|
||||||
/uBlock0.chromium
|
/uBlock0.chromium
|
||||||
/chromium-profile
|
/chromium-profile
|
||||||
|
# direnv
|
||||||
|
/.direnv
|
||||||
|
65
CHANGELOG.md
65
CHANGELOG.md
@@ -1,5 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.31.1 (30/08/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
|
||||||
|
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
|
||||||
|
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
|
||||||
|
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
|
||||||
|
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
|
||||||
|
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
|
||||||
|
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
|
||||||
|
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
|
||||||
|
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
|
||||||
|
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
|
||||||
|
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
|
||||||
|
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
|
||||||
|
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
|
||||||
|
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
|
||||||
|
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
|
||||||
|
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.31.0 (16/06/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
|
||||||
|
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
|
||||||
|
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
|
||||||
|
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
|
||||||
|
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
|
||||||
|
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
|
||||||
|
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
|
||||||
|
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
|
||||||
|
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.30.0 (20/04/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
|
||||||
|
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
|
||||||
|
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
|
||||||
|
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
|
||||||
|
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
|
||||||
|
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
|
||||||
|
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.29.0 (14/04/2024)
|
## v1.29.0 (14/04/2024)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
2
Makefile
2
Makefile
@@ -7,7 +7,7 @@ tasks:
|
|||||||
python manage.py process_tasks
|
python manage.py process_tasks
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest
|
pytest -n auto
|
||||||
|
|
||||||
format:
|
format:
|
||||||
black bookmarks
|
black bookmarks
|
||||||
|
@@ -237,6 +237,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
|||||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
- [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)
|
- [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)
|
- [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)
|
- [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
|
## Acknowledgements + Donations
|
||||||
|
BIN
assets/logo-inset.afdesign
Normal file
BIN
assets/logo-inset.afdesign
Normal file
Binary file not shown.
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
1
assets/logo.svg
Normal file
1
assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
After Width: | Height: | Size: 688 B |
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
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
|
||||||
@@ -11,6 +13,7 @@ from bookmarks.api.serializers import (
|
|||||||
UserProfileSerializer,
|
UserProfileSerializer,
|
||||||
)
|
)
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||||
|
from bookmarks.services import auto_tagging
|
||||||
from bookmarks.services.bookmarks import (
|
from bookmarks.services.bookmarks import (
|
||||||
archive_bookmark,
|
archive_bookmark,
|
||||||
unarchive_bookmark,
|
unarchive_bookmark,
|
||||||
@@ -18,6 +21,8 @@ from bookmarks.services.bookmarks import (
|
|||||||
)
|
)
|
||||||
from bookmarks.services.website_loader import WebsiteMetadata
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(
|
class BookmarkViewSet(
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
@@ -51,7 +56,7 @@ class BookmarkViewSet(
|
|||||||
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 {"user": self.request.user}
|
return {"request": self.request, "user": self.request.user}
|
||||||
|
|
||||||
@action(methods=["get"], detail=False)
|
@action(methods=["get"], detail=False)
|
||||||
def archived(self, request):
|
def archived(self, request):
|
||||||
@@ -59,8 +64,8 @@ class BookmarkViewSet(
|
|||||||
search = BookmarkSearch.from_request(request.GET)
|
search = BookmarkSearch.from_request(request.GET)
|
||||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||||
page = self.paginate_queryset(query_set)
|
page = self.paginate_queryset(query_set)
|
||||||
serializer = self.get_serializer_class()
|
serializer = self.get_serializer(page, many=True)
|
||||||
data = serializer(page, many=True).data
|
data = serializer.data
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
@action(methods=["get"], detail=False)
|
@action(methods=["get"], detail=False)
|
||||||
@@ -72,8 +77,8 @@ class BookmarkViewSet(
|
|||||||
user, request.user_profile, search, public_only
|
user, request.user_profile, search, public_only
|
||||||
)
|
)
|
||||||
page = self.paginate_queryset(query_set)
|
page = self.paginate_queryset(query_set)
|
||||||
serializer = self.get_serializer_class()
|
serializer = self.get_serializer(page, many=True)
|
||||||
data = serializer(page, many=True).data
|
data = serializer.data
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
@action(methods=["post"], detail=True)
|
@action(methods=["post"], detail=True)
|
||||||
@@ -99,13 +104,32 @@ class BookmarkViewSet(
|
|||||||
# Either return metadata from existing bookmark, or scrape from URL
|
# Either return metadata from existing bookmark, or scrape from URL
|
||||||
if bookmark:
|
if bookmark:
|
||||||
metadata = WebsiteMetadata(
|
metadata = WebsiteMetadata(
|
||||||
url, bookmark.website_title, bookmark.website_description
|
url,
|
||||||
|
bookmark.website_title,
|
||||||
|
bookmark.website_description,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metadata = website_loader.load_website_metadata(url)
|
metadata = website_loader.load_website_metadata(url)
|
||||||
|
|
||||||
|
# Return tags that would be automatically applied to the bookmark
|
||||||
|
profile = request.user.profile
|
||||||
|
auto_tags = []
|
||||||
|
if profile.auto_tagging_rules:
|
||||||
|
try:
|
||||||
|
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||||
|
exc_info=e,
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()},
|
{
|
||||||
|
"bookmark": existing_bookmark_data,
|
||||||
|
"metadata": metadata.to_dict(),
|
||||||
|
"auto_tags": auto_tags,
|
||||||
|
},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
from django.db.models import prefetch_related_objects
|
from django.db.models import prefetch_related_objects
|
||||||
|
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
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
"web_archive_snapshot_url",
|
"web_archive_snapshot_url",
|
||||||
|
"favicon_url",
|
||||||
|
"preview_image_url",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"unread",
|
"unread",
|
||||||
"shared",
|
"shared",
|
||||||
@@ -42,6 +45,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
"web_archive_snapshot_url",
|
"web_archive_snapshot_url",
|
||||||
|
"favicon_url",
|
||||||
|
"preview_image_url",
|
||||||
"date_added",
|
"date_added",
|
||||||
"date_modified",
|
"date_modified",
|
||||||
]
|
]
|
||||||
@@ -56,6 +61,24 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
shared = 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
|
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField(required=False, default=[])
|
tag_names = TagListField(required=False, default=[])
|
||||||
|
favicon_url = serializers.SerializerMethodField()
|
||||||
|
preview_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_favicon_url(self, obj: Bookmark):
|
||||||
|
if not obj.favicon_file:
|
||||||
|
return None
|
||||||
|
request = self.context.get("request")
|
||||||
|
favicon_file_path = static(obj.favicon_file)
|
||||||
|
favicon_url = request.build_absolute_uri(favicon_file_path)
|
||||||
|
return favicon_url
|
||||||
|
|
||||||
|
def get_preview_image_url(self, obj: Bookmark):
|
||||||
|
if not obj.preview_image_file:
|
||||||
|
return None
|
||||||
|
request = self.context.get("request")
|
||||||
|
preview_image_file_path = static(obj.preview_image_file)
|
||||||
|
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||||
|
return preview_image_url
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
bookmark = Bookmark()
|
bookmark = Bookmark()
|
||||||
|
@@ -18,19 +18,5 @@ def toasts(request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def public_shares(request):
|
|
||||||
# Only check for public shares for anonymous users
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
query_set = queries.query_shared_bookmarks(
|
|
||||||
None, request.user_profile, BookmarkSearch(), True
|
|
||||||
)
|
|
||||||
has_public_shares = query_set.count() > 0
|
|
||||||
return {
|
|
||||||
"has_public_shares": has_public_shares,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def app_version(request):
|
def app_version(request):
|
||||||
return {"app_version": utils.app_version}
|
return {"app_version": utils.app_version}
|
||||||
|
@@ -85,3 +85,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
|||||||
|
|
||||||
page.get_by_label("URL").fill(bookmark.url)
|
page.get_by_label("URL").fill(bookmark.url)
|
||||||
expect(details).to_have_attribute("open", value="")
|
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()
|
||||||
|
@@ -2,7 +2,8 @@ import unicodedata
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.contrib.syndication.views import Feed
|
from django.contrib.syndication.views import Feed
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet, prefetch_related_objects
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FeedContext:
|
class FeedContext:
|
||||||
|
request: HttpRequest
|
||||||
feed_token: FeedToken | None
|
feed_token: FeedToken | None
|
||||||
query_set: QuerySet[Bookmark]
|
query_set: QuerySet[Bookmark]
|
||||||
|
|
||||||
@@ -26,13 +28,27 @@ def sanitize(text: str):
|
|||||||
|
|
||||||
|
|
||||||
class BaseBookmarksFeed(Feed):
|
class BaseBookmarksFeed(Feed):
|
||||||
def get_object(self, request, feed_key: str):
|
def get_object(self, request, feed_key: str | None):
|
||||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
search = BookmarkSearch(
|
||||||
query_set = queries.query_bookmarks(
|
q=request.GET.get("q", ""),
|
||||||
feed_token.user, feed_token.user.profile, search
|
unread=request.GET.get("unread", ""),
|
||||||
|
shared=request.GET.get("shared", ""),
|
||||||
)
|
)
|
||||||
return FeedContext(feed_token, query_set)
|
query_set = self.get_query_set(feed_token, search)
|
||||||
|
return FeedContext(request, feed_token, query_set)
|
||||||
|
|
||||||
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def items(self, context: FeedContext):
|
||||||
|
limit = context.request.GET.get("limit", 100)
|
||||||
|
if limit:
|
||||||
|
data = context.query_set[: int(limit)]
|
||||||
|
else:
|
||||||
|
data = list(context.query_set)
|
||||||
|
prefetch_related_objects(data, "tags")
|
||||||
|
return data
|
||||||
|
|
||||||
def item_title(self, item: Bookmark):
|
def item_title(self, item: Bookmark):
|
||||||
return sanitize(item.resolved_title)
|
return sanitize(item.resolved_title)
|
||||||
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
|
|||||||
def item_pubdate(self, item: Bookmark):
|
def item_pubdate(self, item: Bookmark):
|
||||||
return item.date_added
|
return item.date_added
|
||||||
|
|
||||||
|
def item_categories(self, item: Bookmark):
|
||||||
|
return item.tag_names
|
||||||
|
|
||||||
|
|
||||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "All bookmarks"
|
title = "All bookmarks"
|
||||||
description = "All bookmarks"
|
description = "All bookmarks"
|
||||||
|
|
||||||
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
|
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||||
|
|
||||||
def link(self, context: FeedContext):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set
|
|
||||||
|
|
||||||
|
|
||||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "Unread bookmarks"
|
title = "Unread bookmarks"
|
||||||
description = "All unread bookmarks"
|
description = "All unread bookmarks"
|
||||||
|
|
||||||
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
|
return queries.query_bookmarks(
|
||||||
|
feed_token.user, feed_token.user.profile, search
|
||||||
|
).filter(unread=True)
|
||||||
|
|
||||||
def link(self, context: FeedContext):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set.filter(unread=True)
|
|
||||||
|
|
||||||
|
|
||||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "Shared bookmarks"
|
title = "Shared bookmarks"
|
||||||
description = "All shared bookmarks"
|
description = "All shared bookmarks"
|
||||||
|
|
||||||
def get_object(self, request, feed_key: str):
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
return queries.query_shared_bookmarks(
|
||||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
|
||||||
query_set = queries.query_shared_bookmarks(
|
|
||||||
None, feed_token.user.profile, search, False
|
None, feed_token.user.profile, search, False
|
||||||
)
|
)
|
||||||
return FeedContext(feed_token, query_set)
|
|
||||||
|
|
||||||
def link(self, context: FeedContext):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set
|
|
||||||
|
|
||||||
|
|
||||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "Public shared bookmarks"
|
title = "Public shared bookmarks"
|
||||||
description = "All public shared bookmarks"
|
description = "All public shared bookmarks"
|
||||||
|
|
||||||
def get_object(self, request):
|
def get_object(self, request):
|
||||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
return super().get_object(request, None)
|
||||||
default_profile = UserProfile()
|
|
||||||
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
return FeedContext(None, query_set)
|
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("bookmarks:feeds.public_shared")
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set
|
|
||||||
|
@@ -16,9 +16,13 @@ const mutationObserver = new MutationObserver((mutations) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
mutationObserver.observe(document.body, {
|
window.addEventListener("turbo:load", () => {
|
||||||
childList: true,
|
mutationObserver.observe(document.body, {
|
||||||
subtree: true,
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyBehaviors(document.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
export class Behavior {
|
export class Behavior {
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import "@hotwired/turbo";
|
||||||
import "./behaviors/bookmark-page";
|
import "./behaviors/bookmark-page";
|
||||||
import "./behaviors/bulk-edit";
|
import "./behaviors/bulk-edit";
|
||||||
import "./behaviors/confirm-button";
|
import "./behaviors/confirm-button";
|
||||||
|
@@ -48,6 +48,19 @@ class Command(BaseCommand):
|
|||||||
file_path = os.path.join(root, file)
|
file_path = os.path.join(root, file)
|
||||||
zip_file.write(file_path, os.path.join("favicons", file))
|
zip_file.write(file_path, os.path.join("favicons", file))
|
||||||
|
|
||||||
|
# Backup the previews folder
|
||||||
|
if not os.path.exists(os.path.join("data", "previews")):
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("No previews folder found. Skipping...")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write("Backup bookmark previews...")
|
||||||
|
previews_folder = os.path.join("data", "previews")
|
||||||
|
for root, _, files in os.walk(previews_folder):
|
||||||
|
for file in files:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
zip_file.write(file_path, os.path.join("previews", file))
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
|
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
|
||||||
|
|
||||||
def backup_database(self, backup_db_file):
|
def backup_database(self, backup_db_file):
|
||||||
|
@@ -1,23 +1,40 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import UserProfile, GlobalSettings
|
||||||
|
|
||||||
|
|
||||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||||
|
|
||||||
|
|
||||||
class UserProfileMiddleware:
|
default_global_settings = GlobalSettings()
|
||||||
|
|
||||||
|
standard_profile = UserProfile()
|
||||||
|
standard_profile.enable_favicons = True
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
|
# add global settings to request
|
||||||
|
try:
|
||||||
|
global_settings = GlobalSettings.get()
|
||||||
|
except:
|
||||||
|
global_settings = default_global_settings
|
||||||
|
request.global_settings = global_settings
|
||||||
|
|
||||||
|
# add user profile to request
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
request.user_profile = request.user.profile
|
request.user_profile = request.user.profile
|
||||||
else:
|
else:
|
||||||
request.user_profile = UserProfile()
|
# check if a custom profile for guests exists, otherwise use standard profile
|
||||||
request.user_profile.enable_favicons = True
|
if global_settings.guest_profile_user:
|
||||||
|
request.user_profile = global_settings.guest_profile_user.profile
|
||||||
|
else:
|
||||||
|
request.user_profile = standard_profile
|
||||||
|
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.0.3 on 2024-05-10 07:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0033_userprofile_default_mark_unread"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="preview_image_file",
|
||||||
|
field=models.CharField(blank=True, max_length=512),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="enable_preview_images",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.0.3 on 2024-05-14 08:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="tag_grouping",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")],
|
||||||
|
default="alphabetical",
|
||||||
|
max_length=12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.3 on 2024-05-17 07:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0035_userprofile_tag_grouping"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="auto_tagging_rules",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
38
bookmarks/migrations/0037_globalsettings.py
Normal file
38
bookmarks/migrations/0037_globalsettings.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.0.8 on 2024-08-31 12:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0036_userprofile_auto_tagging_rules"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GlobalSettings",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"landing_page",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("login", "Login"),
|
||||||
|
("shared_bookmarks", "Shared Bookmarks"),
|
||||||
|
],
|
||||||
|
default="login",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.0.8 on 2024-08-31 17:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0037_globalsettings"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="globalsettings",
|
||||||
|
name="guest_profile_user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.8 on 2024-09-14 07:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0038_globalsettings_guest_profile_user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="globalsettings",
|
||||||
|
name="enable_link_prefetch",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
@@ -59,6 +59,7 @@ class Bookmark(models.Model):
|
|||||||
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)
|
||||||
|
preview_image_file = models.CharField(max_length=512, blank=True)
|
||||||
unread = models.BooleanField(default=False)
|
unread = models.BooleanField(default=False)
|
||||||
is_archived = models.BooleanField(default=False)
|
is_archived = models.BooleanField(default=False)
|
||||||
shared = models.BooleanField(default=False)
|
shared = models.BooleanField(default=False)
|
||||||
@@ -83,7 +84,8 @@ class Bookmark(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tag_names(self):
|
def tag_names(self):
|
||||||
return [tag.name for tag in self.tags.all()]
|
names = [tag.name for tag in self.tags.all()]
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||||
@@ -168,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def has_notes(self):
|
def has_notes(self):
|
||||||
return self.instance and self.instance.notes
|
return self.initial.get("notes", None) or (
|
||||||
|
self.instance and self.instance.notes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSearch:
|
class BookmarkSearch:
|
||||||
@@ -351,6 +355,12 @@ class UserProfile(models.Model):
|
|||||||
(TAG_SEARCH_STRICT, "Strict"),
|
(TAG_SEARCH_STRICT, "Strict"),
|
||||||
(TAG_SEARCH_LAX, "Lax"),
|
(TAG_SEARCH_LAX, "Lax"),
|
||||||
]
|
]
|
||||||
|
TAG_GROUPING_ALPHABETICAL = "alphabetical"
|
||||||
|
TAG_GROUPING_DISABLED = "disabled"
|
||||||
|
TAG_GROUPING_CHOICES = [
|
||||||
|
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||||
|
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||||
|
]
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
@@ -391,9 +401,16 @@ class UserProfile(models.Model):
|
|||||||
blank=False,
|
blank=False,
|
||||||
default=TAG_SEARCH_STRICT,
|
default=TAG_SEARCH_STRICT,
|
||||||
)
|
)
|
||||||
|
tag_grouping = models.CharField(
|
||||||
|
max_length=12,
|
||||||
|
choices=TAG_GROUPING_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=TAG_GROUPING_ALPHABETICAL,
|
||||||
|
)
|
||||||
enable_sharing = models.BooleanField(default=False, null=False)
|
enable_sharing = models.BooleanField(default=False, null=False)
|
||||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||||
enable_favicons = models.BooleanField(default=False, null=False)
|
enable_favicons = models.BooleanField(default=False, null=False)
|
||||||
|
enable_preview_images = models.BooleanField(default=False, null=False)
|
||||||
display_url = models.BooleanField(default=False, null=False)
|
display_url = models.BooleanField(default=False, null=False)
|
||||||
display_view_bookmark_action = models.BooleanField(default=True, null=False)
|
display_view_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
|
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
@@ -401,6 +418,7 @@ 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)
|
||||||
|
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)
|
||||||
@@ -417,9 +435,11 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"bookmark_link_target",
|
"bookmark_link_target",
|
||||||
"web_archive_integration",
|
"web_archive_integration",
|
||||||
"tag_search",
|
"tag_search",
|
||||||
|
"tag_grouping",
|
||||||
"enable_sharing",
|
"enable_sharing",
|
||||||
"enable_public_sharing",
|
"enable_public_sharing",
|
||||||
"enable_favicons",
|
"enable_favicons",
|
||||||
|
"enable_preview_images",
|
||||||
"enable_automatic_html_snapshots",
|
"enable_automatic_html_snapshots",
|
||||||
"display_url",
|
"display_url",
|
||||||
"display_view_bookmark_action",
|
"display_view_bookmark_action",
|
||||||
@@ -429,6 +449,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"permanent_notes",
|
"permanent_notes",
|
||||||
"default_mark_unread",
|
"default_mark_unread",
|
||||||
"custom_css",
|
"custom_css",
|
||||||
|
"auto_tagging_rules",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -474,3 +495,46 @@ class FeedToken(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.key
|
return self.key
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettings(models.Model):
|
||||||
|
LANDING_PAGE_LOGIN = "login"
|
||||||
|
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
|
||||||
|
LANDING_PAGE_CHOICES = [
|
||||||
|
(LANDING_PAGE_LOGIN, "Login"),
|
||||||
|
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
|
||||||
|
]
|
||||||
|
|
||||||
|
landing_page = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=LANDING_PAGE_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=LANDING_PAGE_LOGIN,
|
||||||
|
)
|
||||||
|
guest_profile_user = models.ForeignKey(
|
||||||
|
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||||
|
)
|
||||||
|
enable_link_prefetch = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls):
|
||||||
|
instance = GlobalSettings.objects.first()
|
||||||
|
if not instance:
|
||||||
|
instance = GlobalSettings()
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.pk and GlobalSettings.objects.exists():
|
||||||
|
raise Exception("There is already one instance of GlobalSettings")
|
||||||
|
return super(GlobalSettings, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettingsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = GlobalSettings
|
||||||
|
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
|
||||||
|
self.fields["guest_profile_user"].empty_label = "Standard profile"
|
||||||
|
64
bookmarks/services/auto_tagging.py
Normal file
64
bookmarks/services/auto_tagging.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
import re
|
||||||
|
import idna
|
||||||
|
|
||||||
|
|
||||||
|
def get_tags(script: str, url: str):
|
||||||
|
parsed_url = urlparse(url.lower())
|
||||||
|
result = set()
|
||||||
|
|
||||||
|
for line in script.lower().split("\n"):
|
||||||
|
if "#" in line:
|
||||||
|
i = line.index("#")
|
||||||
|
line = line[:i]
|
||||||
|
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# to parse a host name from the pattern URL, ensure it has a scheme
|
||||||
|
pattern_url = "//" + re.sub("^https?://", "", parts[0])
|
||||||
|
parsed_pattern = urlparse(pattern_url)
|
||||||
|
|
||||||
|
if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if parsed_pattern.path and not _path_matches(
|
||||||
|
parsed_pattern.path, parsed_url.path
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if parsed_pattern.query and not _qs_matches(
|
||||||
|
parsed_pattern.query, parsed_url.query
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for tag in parts[1:]:
|
||||||
|
result.add(tag)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _path_matches(expected_path: str, actual_path: str) -> bool:
|
||||||
|
return actual_path.startswith(expected_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _domains_matches(expected_domain: str, actual_domain: str) -> bool:
|
||||||
|
expected_domain = idna.encode(expected_domain)
|
||||||
|
actual_domain = idna.encode(actual_domain)
|
||||||
|
|
||||||
|
return actual_domain.endswith(expected_domain)
|
||||||
|
|
||||||
|
|
||||||
|
def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||||
|
expected_qs = parse_qs(expected_qs, keep_blank_values=True)
|
||||||
|
actual_qs = parse_qs(actual_qs, keep_blank_values=True)
|
||||||
|
|
||||||
|
for key in expected_qs:
|
||||||
|
if key not in actual_qs:
|
||||||
|
return False
|
||||||
|
for value in expected_qs[key]:
|
||||||
|
if value != "" and value not in actual_qs[key]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
@@ -10,6 +10,7 @@ from django.utils import timezone
|
|||||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||||
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__)
|
||||||
@@ -40,6 +41,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
|||||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||||
# Load favicon
|
# Load favicon
|
||||||
tasks.load_favicon(current_user, bookmark)
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
# Load preview image
|
||||||
|
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:
|
||||||
tasks.create_html_snapshot(bookmark)
|
tasks.create_html_snapshot(bookmark)
|
||||||
@@ -58,6 +61,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
|||||||
bookmark.save()
|
bookmark.save()
|
||||||
# Update favicon
|
# Update favicon
|
||||||
tasks.load_favicon(current_user, bookmark)
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
# Update preview image
|
||||||
|
tasks.load_preview_image(current_user, bookmark)
|
||||||
|
|
||||||
if has_url_changed:
|
if has_url_changed:
|
||||||
# Update web archive snapshot, if URL changed
|
# Update web archive snapshot, if URL changed
|
||||||
@@ -238,6 +243,21 @@ def _update_website_metadata(bookmark: Bookmark):
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
if user.profile.auto_tagging_rules:
|
||||||
|
try:
|
||||||
|
auto_tag_names = auto_tagging.get_tags(
|
||||||
|
user.profile.auto_tagging_rules, bookmark.url
|
||||||
|
)
|
||||||
|
for auto_tag_name in auto_tag_names:
|
||||||
|
if auto_tag_name not in tag_names:
|
||||||
|
tag_names.append(auto_tag_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||||
|
exc_info=e,
|
||||||
|
)
|
||||||
|
|
||||||
tags = get_or_create_tags(tag_names, user)
|
tags = get_or_create_tags(tag_names, user)
|
||||||
bookmark.tags.set(tags)
|
bookmark.tags.set(tags)
|
||||||
|
|
||||||
|
@@ -79,10 +79,10 @@ def import_netscape_html(
|
|||||||
for batch in batches:
|
for batch in batches:
|
||||||
_import_batch(batch, user, options, tag_cache, result)
|
_import_batch(batch, user, options, tag_cache, result)
|
||||||
|
|
||||||
# Create snapshots for newly imported bookmarks
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
|
||||||
# Load favicons for newly imported bookmarks
|
# Load favicons for newly imported bookmarks
|
||||||
tasks.schedule_bookmarks_without_favicons(user)
|
tasks.schedule_bookmarks_without_favicons(user)
|
||||||
|
# Load previews for newly imported bookmarks
|
||||||
|
tasks.schedule_bookmarks_without_previews(user)
|
||||||
|
|
||||||
end = timezone.now()
|
end = timezone.now()
|
||||||
logger.debug(f"Import duration: {end - import_start}")
|
logger.debug(f"Import duration: {end - import_start}")
|
||||||
|
88
bookmarks/services/preview_image_loader.py
Normal file
88
bookmarks/services/preview_image_loader.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os.path
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from bookmarks.services import website_loader
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_preview_folder():
|
||||||
|
Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_to_filename(preview_image: str) -> str:
|
||||||
|
return hashlib.md5(preview_image.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_image_path(preview_image_file: str) -> Path:
|
||||||
|
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file))
|
||||||
|
|
||||||
|
|
||||||
|
def load_preview_image(url: str) -> str | None:
|
||||||
|
_ensure_preview_folder()
|
||||||
|
|
||||||
|
metadata = website_loader.load_website_metadata(url)
|
||||||
|
if not metadata.preview_image:
|
||||||
|
logger.debug(f"Could not find preview image in metadata: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
image_url = metadata.preview_image
|
||||||
|
|
||||||
|
logger.debug(f"Loading preview image: {image_url}")
|
||||||
|
with requests.get(image_url, stream=True) as response:
|
||||||
|
if response.status_code < 200 or response.status_code >= 300:
|
||||||
|
logger.debug(
|
||||||
|
f"Bad response status code for preview image: {image_url} status_code={response.status_code}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "Content-Length" not in response.headers:
|
||||||
|
logger.debug(f"Empty Content-Length for preview image: {image_url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
content_length = int(response.headers["Content-Length"])
|
||||||
|
if content_length > settings.LD_PREVIEW_MAX_SIZE:
|
||||||
|
logger.debug(
|
||||||
|
f"Content-Length exceeds LD_PREVIEW_MAX_SIZE: {image_url} length={content_length}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "Content-Type" not in response.headers:
|
||||||
|
logger.debug(f"Empty Content-Type for preview image: {image_url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
content_type = response.headers["Content-Type"].split(";", 1)[0]
|
||||||
|
file_extension = mimetypes.guess_extension(content_type)
|
||||||
|
|
||||||
|
if file_extension not in settings.LD_PREVIEW_ALLOWED_EXTENSIONS:
|
||||||
|
logger.debug(
|
||||||
|
f"Unsupported Content-Type for preview image: {image_url} content_type={content_type}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
preview_image_hash = _url_to_filename(url)
|
||||||
|
preview_image_file = f"{preview_image_hash}{file_extension}"
|
||||||
|
preview_image_path = _get_image_path(preview_image_file)
|
||||||
|
|
||||||
|
with open(preview_image_path, "wb") as file:
|
||||||
|
downloaded = 0
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded > content_length:
|
||||||
|
logger.debug(
|
||||||
|
f"Content-Length mismatch for preview image: {image_url} length={content_length} downloaded={downloaded}"
|
||||||
|
)
|
||||||
|
file.close()
|
||||||
|
preview_image_path.unlink()
|
||||||
|
return None
|
||||||
|
|
||||||
|
file.write(chunk)
|
||||||
|
|
||||||
|
logger.debug(f"Saved preview image as: {preview_image_path}")
|
||||||
|
|
||||||
|
return preview_image_file
|
@@ -9,7 +9,7 @@ import subprocess
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class SingeFileError(Exception):
|
class SingleFileError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def create_snapshot(url: str, filepath: str):
|
|||||||
|
|
||||||
# check if the file was created
|
# check if the file was created
|
||||||
if not os.path.exists(temp_filepath):
|
if not os.path.exists(temp_filepath):
|
||||||
raise SingeFileError("Failed to create snapshot")
|
raise SingleFileError("Failed to create snapshot")
|
||||||
|
|
||||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||||
filepath, "wb"
|
filepath, "wb"
|
||||||
@@ -47,12 +47,12 @@ def create_snapshot(url: str, filepath: str):
|
|||||||
)
|
)
|
||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=20)
|
process.wait(timeout=20)
|
||||||
raise SingeFileError("Timeout expired while creating snapshot")
|
raise SingleFileError("Timeout expired while creating snapshot")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
# Kill the whole process group, which should also clean up any chromium
|
# Kill the whole process group, which should also clean up any chromium
|
||||||
# processes spawned by single-file
|
# processes spawned by single-file
|
||||||
logger.error("Timeout expired while terminating. Killing process...")
|
logger.error("Timeout expired while terminating. Killing process...")
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||||
raise SingeFileError("Timeout expired while creating snapshot")
|
raise SingleFileError("Timeout expired while creating snapshot")
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")
|
raise SingleFileError(f"Failed to create snapshot: {error.stderr}")
|
||||||
|
@@ -7,15 +7,15 @@ import waybackpy
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
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.utils import timezone, formats
|
from django.utils import timezone, formats
|
||||||
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, NoCDXRecordFound
|
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||||
|
|
||||||
import bookmarks.services.wayback
|
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import favicon_loader, singlefile
|
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -65,29 +65,6 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
|
|||||||
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
||||||
|
|
||||||
|
|
||||||
def _load_newest_snapshot(bookmark: Bookmark):
|
|
||||||
try:
|
|
||||||
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
|
|
||||||
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
|
|
||||||
bookmark.url
|
|
||||||
)
|
|
||||||
existing_snapshot = cdx_api.newest()
|
|
||||||
|
|
||||||
if existing_snapshot:
|
|
||||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
|
||||||
bookmark.save(update_fields=["web_archive_snapshot_url"])
|
|
||||||
logger.info(
|
|
||||||
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except NoCDXRecordFound:
|
|
||||||
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
|
|
||||||
except WaybackError as error:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_snapshot(bookmark: Bookmark):
|
def _create_snapshot(bookmark: Bookmark):
|
||||||
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
||||||
archive = waybackpy.WaybackMachineSaveAPI(
|
archive = waybackpy.WaybackMachineSaveAPI(
|
||||||
@@ -116,48 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
|||||||
return
|
return
|
||||||
except TooManyRequestsError:
|
except TooManyRequestsError:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}"
|
f"Failed to create snapshot due to rate limiting. url={bookmark.url}"
|
||||||
)
|
)
|
||||||
except WaybackError as error:
|
except WaybackError as error:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}",
|
f"Failed to create snapshot. url={bookmark.url}",
|
||||||
exc_info=error,
|
exc_info=error,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load the newest snapshot as fallback
|
|
||||||
_load_newest_snapshot(bookmark)
|
|
||||||
|
|
||||||
|
|
||||||
@task()
|
@task()
|
||||||
def _load_web_archive_snapshot_task(bookmark_id: int):
|
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||||
try:
|
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
# for now to prevent errors when huey tries to run the task
|
||||||
except Bookmark.DoesNotExist:
|
pass
|
||||||
return
|
|
||||||
# Skip if snapshot exists
|
|
||||||
if bookmark.web_archive_snapshot_url:
|
|
||||||
return
|
|
||||||
# Load the newest snapshot
|
|
||||||
_load_newest_snapshot(bookmark)
|
|
||||||
|
|
||||||
|
|
||||||
def schedule_bookmarks_without_snapshots(user: User):
|
|
||||||
if is_web_archive_integration_active(user):
|
|
||||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@task()
|
@task()
|
||||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||||
user = get_user_model().objects.get(id=user_id)
|
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
# for now to prevent errors when huey tries to run the task
|
||||||
web_archive_snapshot_url__exact="", owner=user
|
pass
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Implement bulk task creation
|
|
||||||
for bookmark in bookmarks_without_snapshots:
|
|
||||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
|
||||||
# new ones when processing bookmarks in bulk
|
|
||||||
_load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
|
|
||||||
|
|
||||||
def is_favicon_feature_active(user: User) -> bool:
|
def is_favicon_feature_active(user: User) -> bool:
|
||||||
@@ -166,6 +122,12 @@ def is_favicon_feature_active(user: User) -> bool:
|
|||||||
return background_tasks_enabled and user.profile.enable_favicons
|
return background_tasks_enabled and user.profile.enable_favicons
|
||||||
|
|
||||||
|
|
||||||
|
def is_preview_feature_active(user: User) -> bool:
|
||||||
|
return (
|
||||||
|
user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_favicon(user: User, bookmark: Bookmark):
|
def load_favicon(user: User, bookmark: Bookmark):
|
||||||
if is_favicon_feature_active(user):
|
if is_favicon_feature_active(user):
|
||||||
_load_favicon_task(bookmark.id)
|
_load_favicon_task(bookmark.id)
|
||||||
@@ -221,6 +183,51 @@ def _schedule_refresh_favicons_task(user_id: int):
|
|||||||
_load_favicon_task(bookmark.id)
|
_load_favicon_task(bookmark.id)
|
||||||
|
|
||||||
|
|
||||||
|
def load_preview_image(user: User, bookmark: Bookmark):
|
||||||
|
if is_preview_feature_active(user):
|
||||||
|
_load_preview_image_task(bookmark.id)
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def _load_preview_image_task(bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Load preview image for bookmark. url={bookmark.url}")
|
||||||
|
|
||||||
|
new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url)
|
||||||
|
|
||||||
|
if new_preview_image_file != bookmark.preview_image_file:
|
||||||
|
bookmark.preview_image_file = new_preview_image_file or ""
|
||||||
|
bookmark.save(update_fields=["preview_image_file"])
|
||||||
|
logger.info(
|
||||||
|
f"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_bookmarks_without_previews(user: User):
|
||||||
|
if is_preview_feature_active(user):
|
||||||
|
_schedule_bookmarks_without_previews_task(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
bookmarks = Bookmark.objects.filter(
|
||||||
|
Q(preview_image_file__exact=""),
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: Implement bulk task creation
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
try:
|
||||||
|
_load_preview_image_task(bookmark.id)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(exc)
|
||||||
|
|
||||||
|
|
||||||
def is_html_snapshot_feature_active() -> bool:
|
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
|
||||||
|
|
||||||
|
@@ -1,42 +1,20 @@
|
|||||||
import time
|
import datetime
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
import waybackpy
|
from django.utils import timezone
|
||||||
import waybackpy.utils
|
|
||||||
from waybackpy.exceptions import NoCDXRecordFound
|
|
||||||
|
|
||||||
|
|
||||||
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
def generate_fallback_webarchive_url(
|
||||||
|
url: str, timestamp: datetime.datetime
|
||||||
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
|
Generate a URL to the web archive for the given URL and timestamp.
|
||||||
See https://github.com/akamhy/waybackpy/issues/176
|
A snapshot for the specific timestamp might not exist, in which case the
|
||||||
|
web archive will show the closest snapshot to the given timestamp.
|
||||||
|
If there is no snapshot at all the URL will be invalid.
|
||||||
"""
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
if not timestamp:
|
||||||
|
timestamp = timezone.now()
|
||||||
|
|
||||||
def newest(self):
|
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"
|
||||||
unix_timestamp = int(time.time())
|
|
||||||
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
|
|
||||||
unix_timestamp
|
|
||||||
)
|
|
||||||
self.sort = "closest"
|
|
||||||
self.limit = -5
|
|
||||||
|
|
||||||
newest_snapshot = None
|
|
||||||
for snapshot in self.snapshots():
|
|
||||||
newest_snapshot = snapshot
|
|
||||||
break
|
|
||||||
|
|
||||||
if not newest_snapshot:
|
|
||||||
raise NoCDXRecordFound(
|
|
||||||
"Wayback Machine's CDX server did not return any records "
|
|
||||||
+ "for the query. The URL may not have any archives "
|
|
||||||
+ " on the Wayback Machine or the URL may have been recently "
|
|
||||||
+ "archived and is still not available on the CDX server."
|
|
||||||
)
|
|
||||||
|
|
||||||
return newest_snapshot
|
|
||||||
|
|
||||||
def add_payload(self, payload: Dict[str, str]) -> None:
|
|
||||||
super().add_payload(payload)
|
|
||||||
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
|
|
||||||
# makes searching for latest snapshots faster
|
|
||||||
payload["fastLatest"] = "true"
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -15,12 +16,14 @@ class WebsiteMetadata:
|
|||||||
url: str
|
url: str
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
preview_image: str | None
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
|
"preview_image": self.preview_image,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ class WebsiteMetadata:
|
|||||||
def load_website_metadata(url: str):
|
def load_website_metadata(url: str):
|
||||||
title = None
|
title = None
|
||||||
description = None
|
description = None
|
||||||
|
preview_image = None
|
||||||
try:
|
try:
|
||||||
start = timezone.now()
|
start = timezone.now()
|
||||||
page_text = load_page(url)
|
page_text = load_page(url)
|
||||||
@@ -55,10 +59,21 @@ def load_website_metadata(url: str):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
image_tag = soup.find("meta", attrs={"property": "og:image"})
|
||||||
|
preview_image = image_tag["content"].strip() if image_tag else None
|
||||||
|
if (
|
||||||
|
preview_image
|
||||||
|
and not preview_image.startswith("http://")
|
||||||
|
and not preview_image.startswith("https://")
|
||||||
|
):
|
||||||
|
preview_image = urljoin(url, preview_image)
|
||||||
|
|
||||||
end = timezone.now()
|
end = timezone.now()
|
||||||
logger.debug(f"Parsing duration: {end - start}")
|
logger.debug(f"Parsing duration: {end - start}")
|
||||||
finally:
|
finally:
|
||||||
return WebsiteMetadata(url=url, title=title, description=description)
|
return WebsiteMetadata(
|
||||||
|
url=url, title=title, description=description, preview_image=preview_image
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
CHUNK_SIZE = 50 * 1024
|
CHUNK_SIZE = 50 * 1024
|
||||||
|
@@ -1,15 +1,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import user_logged_in
|
|
||||||
from django.db.backends.signals import connection_created
|
from django.db.backends.signals import connection_created
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from bookmarks.services import tasks
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
|
||||||
def user_logged_in(sender, request, user, **kwargs):
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(connection_created)
|
@receiver(connection_created)
|
||||||
def extend_sqlite(connection=None, **kwargs):
|
def extend_sqlite(connection=None, **kwargs):
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 447 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,136 +0,0 @@
|
|||||||
/* Main layout */
|
|
||||||
body {
|
|
||||||
margin: 20px 10px;
|
|
||||||
|
|
||||||
@media (min-width: $size-sm) {
|
|
||||||
// Horizontal padding accounts for checkboxes that show up in bulk edit mode
|
|
||||||
margin: 20px 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
margin-bottom: $unit-9;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 0 $unit-3;
|
|
||||||
font-size: $font-size-lg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
header .toasts {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast a.btn-clear:visited {
|
|
||||||
color: currentColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shared components */
|
|
||||||
|
|
||||||
// Content area component
|
|
||||||
section.content-area {
|
|
||||||
h2 {
|
|
||||||
font-size: $font-size-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-area-header {
|
|
||||||
border-bottom: solid 1px $border-color;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
column-gap: $unit-5;
|
|
||||||
padding-bottom: $unit-1;
|
|
||||||
margin-bottom: $unit-3;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
line-height: $unit-9;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-controls {
|
|
||||||
flex: 1 1 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm button component
|
|
||||||
span.confirmation {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: $unit-1;
|
|
||||||
color: $error-color !important;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.btn-link {
|
|
||||||
color: $error-color !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional utilities */
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-gray-dark {
|
|
||||||
color: $gray-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-baseline {
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-between {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-4 {
|
|
||||||
margin-bottom: $unit-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-auto {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.btn-wide {
|
|
||||||
padding-left: $unit-6;
|
|
||||||
padding-right: $unit-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.btn-sm.btn-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: $unit-h;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
}
|
|
150
bookmarks/styles/bookmark-details.css
Normal file
150
bookmarks/styles/bookmark-details.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/* Common styles */
|
||||||
|
.bookmark-details {
|
||||||
|
& h2 {
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .weblinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a.weblink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a.weblink img, & a.weblink svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a.weblink span {
|
||||||
|
flex: 1 1 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .preview-image {
|
||||||
|
margin: var(--unit-4 0);
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& dl {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .assets {
|
||||||
|
margin-top: var(--unit-2);
|
||||||
|
|
||||||
|
& .asset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
padding: var(--unit-2) 0;
|
||||||
|
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset:last-child {
|
||||||
|
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-text {
|
||||||
|
flex: 1 1 0;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-text .truncate {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-text .filesize {
|
||||||
|
color: var(--tertiary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& .btn.btn-link {
|
||||||
|
height: unset;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .assets-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--unit-2);
|
||||||
|
|
||||||
|
& .btn.btn-link {
|
||||||
|
height: unset;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .tags a {
|
||||||
|
color: var(--alternative-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .status form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .status .form-group, .status .form-switch {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details view specific */
|
||||||
|
.bookmark-details.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--unit-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details modal specific */
|
||||||
|
.bookmark-details.modal {
|
||||||
|
& .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .modal-body {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,132 +0,0 @@
|
|||||||
/* Common styles */
|
|
||||||
.bookmark-details {
|
|
||||||
h2 {
|
|
||||||
flex: 1 1 0;
|
|
||||||
align-items: flex-start;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weblinks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.weblink {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.weblink img, a.weblink svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
color: $body-font-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.weblink span {
|
|
||||||
flex: 1 1 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
dl {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets {
|
|
||||||
margin-top: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-3;
|
|
||||||
padding: $unit-2 0;
|
|
||||||
border-top: $unit-o solid $border-color-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset:last-child {
|
|
||||||
border-bottom: $unit-o solid $border-color-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset-text {
|
|
||||||
flex: 1 1 0;
|
|
||||||
gap: $unit-2;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset-text .truncate {
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset-text .filesize {
|
|
||||||
color: $gray-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset-actions, .assets-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-4;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets .asset-actions .btn, .assets-actions .btn {
|
|
||||||
height: unset;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-actions {
|
|
||||||
margin-top: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags a {
|
|
||||||
color: $alternative-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status form {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status .form-group, .status .form-switch {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark details view specific */
|
|
||||||
.bookmark-details.page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark details modal specific */
|
|
||||||
.bookmark-details.modal {
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
48
bookmarks/styles/bookmark-form.css
Normal file
48
bookmarks/styles/bookmark-form.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
.bookmarks-form-page {
|
||||||
|
section {
|
||||||
|
max-width: 550px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-form {
|
||||||
|
& .btn.btn-link.form-icon {
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
visibility: hidden;
|
||||||
|
--btn-icon-color: var(--tertiary-text-color);
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .has-icon-right > input, & .has-icon-right > textarea {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||||
|
& .has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-icon.loading {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-input-hint.bookmark-exists {
|
||||||
|
display: none;
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-input-hint.auto-tags {
|
||||||
|
display: none;
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& details.notes textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,50 +0,0 @@
|
|||||||
.bookmarks-form {
|
|
||||||
|
|
||||||
.btn.form-icon {
|
|
||||||
padding: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
visibility: hidden;
|
|
||||||
color: $gray-color;
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&.active {
|
|
||||||
color: $gray-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
> svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-icon-right > input, .has-icon-right > textarea {
|
|
||||||
padding-right: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
|
||||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-icon.loading {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input-hint.bookmark-exists {
|
|
||||||
display: none;
|
|
||||||
color: $warning-color;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: $warning-color;
|
|
||||||
text-decoration: underline;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
details.notes textarea {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
457
bookmarks/styles/bookmark-page.css
Normal file
457
bookmarks/styles/bookmark-page.css
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
:root {
|
||||||
|
--bookmark-title-color: var(--primary-text-color);
|
||||||
|
--bookmark-title-weight: 500;
|
||||||
|
--bookmark-description-color: var(--text-color);
|
||||||
|
--bookmark-description-weight: 400;
|
||||||
|
--bookmark-actions-color: var(--secondary-text-color);
|
||||||
|
--bookmark-actions-hover-color: var(--text-color);
|
||||||
|
--bookmark-actions-weight: 400;
|
||||||
|
--bulk-actions-bg-color: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark page grid */
|
||||||
|
.bookmarks-page.grid {
|
||||||
|
grid-gap: var(--unit-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark area header controls */
|
||||||
|
.bookmarks-page .search-container {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
max-width: 300px;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
& form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
max-width: initial;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Regular input */
|
||||||
|
|
||||||
|
& input[type='search'] {
|
||||||
|
height: var(--control-size);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced auto-complete input */
|
||||||
|
/* This needs a bit more wrangling to make the CSS component align with the attached button */
|
||||||
|
|
||||||
|
& .form-autocomplete {
|
||||||
|
height: var(--control-size);
|
||||||
|
|
||||||
|
& .form-autocomplete-input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--control-size);
|
||||||
|
|
||||||
|
& input[type='search'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group search options button with search button */
|
||||||
|
height: var(--control-size);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow-xs);
|
||||||
|
|
||||||
|
& input, & .form-autocomplete-input {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .dropdown-toggle {
|
||||||
|
border-left: none;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search option menu styles */
|
||||||
|
|
||||||
|
& .dropdown {
|
||||||
|
& .menu {
|
||||||
|
padding: var(--unit-4);
|
||||||
|
min-width: 250px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .menu .actions {
|
||||||
|
margin-top: var(--unit-4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-group:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-group {
|
||||||
|
margin-bottom: var(--unit-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .radio-group {
|
||||||
|
& .form-label {
|
||||||
|
margin-bottom: var(--unit-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-radio.form-inline {
|
||||||
|
margin: 0 var(--unit-2) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: var(--unit-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-icon {
|
||||||
|
top: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark list */
|
||||||
|
ul.bookmark-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
/* Increase line-height for better separation within / between items */
|
||||||
|
line-height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appear {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmarks */
|
||||||
|
li[ld-bookmark-item] {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--unit-3);
|
||||||
|
|
||||||
|
& .content {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& img.preview-image {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100px;
|
||||||
|
height: 60px;
|
||||||
|
margin-top: var(--unit-h);
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: solid 1px var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-checkbox.bulk-edit-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title img {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title img + a {
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title a {
|
||||||
|
color: var(--bookmark-title-color);
|
||||||
|
font-weight: var(--bookmark-title-weight);
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title a[data-tooltip]:hover::after, & .title a[data-tooltip]:focus::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: max-content;
|
||||||
|
max-width: 90%;
|
||||||
|
height: fit-content;
|
||||||
|
background-color: #292f62;
|
||||||
|
color: #fff;
|
||||||
|
padding: var(--unit-1);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid #424a8c;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-style: normal;
|
||||||
|
white-space: normal;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: 0.3s ease 0s appear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
& .title a[data-tooltip]::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unread .title a {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .url-path, & .url-display {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--secondary-link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .description {
|
||||||
|
color: var(--bookmark-description-color);
|
||||||
|
font-weight: var(--bookmark-description-weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .description.separate {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .tags {
|
||||||
|
& a, & a:visited:hover {
|
||||||
|
color: var(--alternative-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .actions, & .extra-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
& .extra-actions {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--unit-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .actions {
|
||||||
|
color: var(--bookmark-actions-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
& a, & button.btn-link {
|
||||||
|
color: var(--bookmark-actions-color);
|
||||||
|
--btn-icon-color: var(--bookmark-actions-color);
|
||||||
|
font-weight: var(--bookmark-actions-weight);
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: unset;
|
||||||
|
border: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: none;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: var(--bookmark-actions-hover-color);
|
||||||
|
--btn-icon-color: var(--bookmark-actions-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-pagination {
|
||||||
|
margin-top: var(--unit-4);
|
||||||
|
|
||||||
|
/* Remove left padding from first pagination link */
|
||||||
|
|
||||||
|
& .page-item:first-child a {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud {
|
||||||
|
/* Increase line-height for better separation within / between items */
|
||||||
|
line-height: 1.1rem;
|
||||||
|
|
||||||
|
& .selected-tags {
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
|
||||||
|
& a,
|
||||||
|
& a:visited:hover {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .unselected-tags {
|
||||||
|
& a,
|
||||||
|
& a:visited:hover {
|
||||||
|
color: var(--alternative-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .group {
|
||||||
|
margin-bottom: var(--unit-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .highlight-char {
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--alternative-color-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark notes */
|
||||||
|
ul.bookmark-list {
|
||||||
|
& .notes {
|
||||||
|
display: none;
|
||||||
|
max-height: 300px;
|
||||||
|
margin: var(--unit-1) 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--body-color-contrast);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .notes .markdown {
|
||||||
|
padding: var(--unit-2) var(--unit-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-notes .notes,
|
||||||
|
& li.show-notes .notes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark bulk edit */
|
||||||
|
:root {
|
||||||
|
--bulk-edit-toggle-width: 16px;
|
||||||
|
--bulk-edit-toggle-offset: 8px;
|
||||||
|
--bulk-edit-bar-offset: calc(var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset)));
|
||||||
|
--bulk-edit-transition-duration: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ld-bulk-edit] {
|
||||||
|
& .bulk-edit-bar {
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height var(--bulk-edit-transition-duration);
|
||||||
|
background: var(--bulk-actions-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .bulk-edit-bar {
|
||||||
|
max-height: 37px;
|
||||||
|
border-bottom: solid 1px var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
|
||||||
|
&.active section:first-of-type .content-area-header {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
||||||
|
|
||||||
|
&.active:not(.activating) .bulk-edit-bar {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All checkbox */
|
||||||
|
|
||||||
|
& .form-checkbox.bulk-edit-checkbox.all {
|
||||||
|
display: block;
|
||||||
|
width: var(--bulk-edit-toggle-width);
|
||||||
|
margin: 0 0 0 var(--bulk-edit-toggle-offset);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark checkboxes */
|
||||||
|
|
||||||
|
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: var(--bulk-edit-toggle-width);
|
||||||
|
min-height: var(--bulk-edit-toggle-width);
|
||||||
|
left: calc(-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset));
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all var(--bulk-edit-transition-duration);
|
||||||
|
|
||||||
|
.form-icon {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
|
||||||
|
& .bulk-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--unit-1) 0;
|
||||||
|
border-top: solid 1px var(--secondary-border-color);
|
||||||
|
gap: var(--unit-2);
|
||||||
|
|
||||||
|
& button {
|
||||||
|
--control-padding-x-sm: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > input,
|
||||||
|
& .form-autocomplete,
|
||||||
|
& select {
|
||||||
|
width: auto;
|
||||||
|
max-width: 140px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .select-across {
|
||||||
|
margin: 0 0 0 auto;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,385 +0,0 @@
|
|||||||
.bookmarks-page.grid {
|
|
||||||
grid-gap: $unit-9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark area header controls */
|
|
||||||
.bookmarks-page .content-area-header {
|
|
||||||
--searchbox-max-width: 350px;
|
|
||||||
|
|
||||||
@media (max-width: $size-sm) {
|
|
||||||
--searchbox-max-width: initial;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarks-page .search-container {
|
|
||||||
flex: 1 1 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
// Regular input
|
|
||||||
input[type='search'] {
|
|
||||||
height: $control-size;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced auto-complete input
|
|
||||||
// This needs a bit more wrangling to make the CSS component align with the attached button
|
|
||||||
.form-autocomplete {
|
|
||||||
height: $control-size;
|
|
||||||
|
|
||||||
.form-autocomplete-input {
|
|
||||||
width: 100%;
|
|
||||||
height: $control-size;
|
|
||||||
|
|
||||||
input[type='search'] {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: var(--searchbox-min-width);
|
|
||||||
max-width: var(--searchbox-max-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group > :first-child {
|
|
||||||
flex: 1 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group search options button with search button
|
|
||||||
.input-group input[type='submit'] {
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
margin-left: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search option menu styles
|
|
||||||
.dropdown {
|
|
||||||
.menu {
|
|
||||||
padding: $unit-4;
|
|
||||||
min-width: 250px;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu .actions {
|
|
||||||
margin-top: $unit-4;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-group {
|
|
||||||
margin-bottom: $unit-1;
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-radio.form-inline {
|
|
||||||
margin: 0 $unit-2 0 0;
|
|
||||||
padding: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: $unit-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-icon {
|
|
||||||
top: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark list */
|
|
||||||
ul.bookmark-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
/* Increase line-height for better separation within / between items */
|
|
||||||
line-height: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes appear {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmarks */
|
|
||||||
li[ld-bookmark-item] {
|
|
||||||
position: relative;
|
|
||||||
margin-top: $unit-2;
|
|
||||||
|
|
||||||
.form-checkbox.bulk-edit-checkbox {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title img {
|
|
||||||
position: absolute;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
left: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title img + a {
|
|
||||||
padding-left: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title a {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title a[data-tooltip]:hover::after, .title a[data-tooltip]:focus::after {
|
|
||||||
content: attr(data-tooltip);
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
top: 100%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: max-content;
|
|
||||||
max-width: 90%;
|
|
||||||
height: fit-content;
|
|
||||||
background-color: #292f62;
|
|
||||||
color: #fff;
|
|
||||||
padding: $unit-1;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
border: 1px solid #424a8c;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
font-style: normal;
|
|
||||||
white-space: normal;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: 0.3s ease 0s appear;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.unread .title a {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-path, .url-display {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $secondary-link-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
color: $gray-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description.separate {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags {
|
|
||||||
a, a:visited:hover {
|
|
||||||
color: $alternative-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions, .extra-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
column-gap: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $size-sm) {
|
|
||||||
.extra-actions {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: $unit-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
|
|
||||||
a, button.btn-link {
|
|
||||||
color: $gray-color;
|
|
||||||
padding: 0;
|
|
||||||
height: auto;
|
|
||||||
vertical-align: unset;
|
|
||||||
border: none;
|
|
||||||
transition: none;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&.active {
|
|
||||||
color: $gray-color-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmark-pagination {
|
|
||||||
margin-top: $unit-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-cloud {
|
|
||||||
/* Increase line-height for better separation within / between items */
|
|
||||||
line-height: 1.1rem;
|
|
||||||
|
|
||||||
.selected-tags {
|
|
||||||
margin-bottom: $unit-4;
|
|
||||||
|
|
||||||
a, a:visited:hover {
|
|
||||||
color: $error-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.unselected-tags {
|
|
||||||
a, a:visited:hover {
|
|
||||||
color: $alternative-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
margin-bottom: $unit-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-char {
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: $alternative-color-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark notes */
|
|
||||||
ul.bookmark-list {
|
|
||||||
.notes {
|
|
||||||
display: none;
|
|
||||||
max-height: 300px;
|
|
||||||
margin: $unit-1 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes .markdown {
|
|
||||||
padding: $unit-2 $unit-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.show-notes .notes,
|
|
||||||
li.show-notes .notes {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark bulk edit */
|
|
||||||
$bulk-edit-toggle-width: 16px;
|
|
||||||
$bulk-edit-toggle-offset: 8px;
|
|
||||||
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
|
||||||
$bulk-edit-transition-duration: 400ms;
|
|
||||||
|
|
||||||
[ld-bulk-edit] {
|
|
||||||
.bulk-edit-bar {
|
|
||||||
margin-top: -1px;
|
|
||||||
margin-left: -$bulk-edit-bar-offset;
|
|
||||||
margin-bottom: $unit-3;
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height $bulk-edit-transition-duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active .bulk-edit-bar {
|
|
||||||
max-height: 37px;
|
|
||||||
border-bottom: solid 1px $border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
|
||||||
&.active:not(.activating) .bulk-edit-bar {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* All checkbox */
|
|
||||||
.form-checkbox.bulk-edit-checkbox.all {
|
|
||||||
display: block;
|
|
||||||
width: $bulk-edit-toggle-width;
|
|
||||||
margin: 0 0 0 $bulk-edit-toggle-offset;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark checkboxes */
|
|
||||||
li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
width: $bulk-edit-toggle-width;
|
|
||||||
min-height: $bulk-edit-toggle-width;
|
|
||||||
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition: all $bulk-edit-transition-duration;
|
|
||||||
|
|
||||||
.form-icon {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.bulk-edit-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: $unit-1 0;
|
|
||||||
border-top: solid 1px $border-color;
|
|
||||||
gap: $unit-2;
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
> input, .form-autocomplete, select {
|
|
||||||
width: auto;
|
|
||||||
max-width: 140px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-across {
|
|
||||||
margin: 0 0 0 auto;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
65
bookmarks/styles/components.css
Normal file
65
bookmarks/styles/components.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/* Shared components */
|
||||||
|
|
||||||
|
/* Content area component */
|
||||||
|
section.content-area {
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area-header {
|
||||||
|
border-bottom: solid 1px var(--secondary-border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: var(--unit-5);
|
||||||
|
padding-bottom: var(--unit-2);
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
line-height: var(--unit-9);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
section.content-area .content-area-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm button component */
|
||||||
|
span.confirmation {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--unit-1);
|
||||||
|
color: var(--error-color) !important;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-link {
|
||||||
|
color: var(--error-color) !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.divider {
|
||||||
|
border-bottom: solid 1px var(--secondary-border-color);
|
||||||
|
margin: var(--unit-5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Turbo progress bar */
|
||||||
|
.turbo-progress-bar {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
39
bookmarks/styles/layout.css
Normal file
39
bookmarks/styles/layout.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* Main layout */
|
||||||
|
body {
|
||||||
|
margin: 20px 10px;
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
/* Horizontal offset accounts for checkboxes that show up in bulk edit mode */
|
||||||
|
margin: 20px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: var(--unit-9);
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0 var(--unit-3);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header .toasts {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast a.btn-clear:visited {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
40
bookmarks/styles/markdown.css
Normal file
40
bookmarks/styles/markdown.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.markdown {
|
||||||
|
& p, & ul, & ol, & pre, & blockquote {
|
||||||
|
margin: 0 0 var(--unit-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ul, & ol {
|
||||||
|
margin-left: var(--unit-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
& ul li, & ol li {
|
||||||
|
margin-top: var(--unit-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& pre {
|
||||||
|
padding: var(--unit-1) var(--unit-2);
|
||||||
|
background-color: var(--code-bg-color);
|
||||||
|
border-radius: var(--unit-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
& pre code {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > pre:first-child:last-child {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,40 +0,0 @@
|
|||||||
.markdown {
|
|
||||||
p, ul, ol, pre, blockquote {
|
|
||||||
margin: 0 0 $unit-2 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
margin-left: $unit-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li, ol li {
|
|
||||||
margin-top: $unit-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
padding: $unit-1 $unit-2;
|
|
||||||
background-color: $code-bg-color;
|
|
||||||
border-radius: $unit-1;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre code {
|
|
||||||
background: none;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> pre:first-child:last-child {
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,10 +1,3 @@
|
|||||||
.container {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: $size-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-sm,
|
.show-sm,
|
||||||
.show-md {
|
.show-md {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -26,11 +19,18 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
--grid-columns: 3;
|
--grid-columns: 3;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||||
grid-gap: $unit-4;
|
grid-gap: var(--unit-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid > * {
|
.grid > * {
|
||||||
@@ -46,18 +46,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.col-1 {
|
.col-1 {
|
||||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
grid-column: span min(1, var(--grid-columns));
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-2 {
|
.col-2 {
|
||||||
grid-column: unquote("span min(2, var(--grid-columns))");
|
grid-column: span min(2, var(--grid-columns));
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-3 {
|
.col-3 {
|
||||||
grid-column: unquote("span min(3, var(--grid-columns))");
|
grid-column: span min(3, var(--grid-columns));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $size-md) {
|
@media (max-width: 840px) {
|
||||||
.hide-md {
|
.hide-md {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $size-sm) {
|
@media (max-width: 600px) {
|
||||||
.hide-sm {
|
.hide-sm {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
26
bookmarks/styles/settings.css
Normal file
26
bookmarks/styles/settings.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.settings-page {
|
||||||
|
section.content-area {
|
||||||
|
margin-bottom: var(--unit-10);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--unit-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > input[type=submit] {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.about table {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-group {
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,21 +0,0 @@
|
|||||||
.settings-page {
|
|
||||||
section.content-area {
|
|
||||||
margin-bottom: $unit-10;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: $unit-3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.custom-css {
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group > input[type=submit] {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
section.about table {
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,197 +0,0 @@
|
|||||||
// Customized Spectre CSS imports, removing modules that are not used
|
|
||||||
// See node_modules/spectre.css/src/spectre.scss for the original version
|
|
||||||
|
|
||||||
// Variables and mixins
|
|
||||||
@import "../../node_modules/spectre.css/src/variables";
|
|
||||||
|
|
||||||
// Customize variables to reduce font and control sizes
|
|
||||||
|
|
||||||
// Can use CSS variables for font sizes, as they are not used in SCSS calculations
|
|
||||||
$font-size: var(--font-size);
|
|
||||||
$font-size-sm: var(--font-size-sm);
|
|
||||||
$font-size-lg: var(--font-size-lg);
|
|
||||||
|
|
||||||
// Can't use CSS variables for these, used in SCSS calculations
|
|
||||||
$line-height: 1rem;
|
|
||||||
$control-size: $unit-8;
|
|
||||||
$control-size-sm: $unit-6;
|
|
||||||
$control-size-lg: $unit-9;
|
|
||||||
|
|
||||||
// Declare defaults for CSS variables, expose SCSS variables as CSS variables
|
|
||||||
html {
|
|
||||||
--font-size: 0.7rem;
|
|
||||||
--font-size-sm: 0.65rem;
|
|
||||||
--font-size-lg: 0.8rem;
|
|
||||||
|
|
||||||
--control-size: #{$control-size};
|
|
||||||
--control-size-sm: #{$control-size-sm};
|
|
||||||
--control-size-lg: #{$control-size-lg};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mixins
|
|
||||||
@import "../../node_modules/spectre.css/src/mixins";
|
|
||||||
|
|
||||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
|
||||||
// Reset and dependencies
|
|
||||||
@import "../../node_modules/spectre.css/src/normalize";
|
|
||||||
@import "../../node_modules/spectre.css/src/base";
|
|
||||||
|
|
||||||
// Elements
|
|
||||||
@import "../../node_modules/spectre.css/src/typography";
|
|
||||||
@import "../../node_modules/spectre.css/src/asian";
|
|
||||||
@import "../../node_modules/spectre.css/src/tables";
|
|
||||||
@import "../../node_modules/spectre.css/src/buttons";
|
|
||||||
@import "../../node_modules/spectre.css/src/forms";
|
|
||||||
@import "../../node_modules/spectre.css/src/labels";
|
|
||||||
@import "../../node_modules/spectre.css/src/codes";
|
|
||||||
@import "../../node_modules/spectre.css/src/media";
|
|
||||||
|
|
||||||
// Components
|
|
||||||
@import "../../node_modules/spectre.css/src/badges";
|
|
||||||
@import "../../node_modules/spectre.css/src/dropdowns";
|
|
||||||
@import "../../node_modules/spectre.css/src/empty";
|
|
||||||
@import "../../node_modules/spectre.css/src/menus";
|
|
||||||
@import "../../node_modules/spectre.css/src/modals";
|
|
||||||
@import "../../node_modules/spectre.css/src/pagination";
|
|
||||||
@import "../../node_modules/spectre.css/src/tabs";
|
|
||||||
@import "../../node_modules/spectre.css/src/toasts";
|
|
||||||
@import "../../node_modules/spectre.css/src/tooltips";
|
|
||||||
|
|
||||||
// Utility classes
|
|
||||||
@import "../../node_modules/spectre.css/src/animations";
|
|
||||||
@import "../../node_modules/spectre.css/src/utilities";
|
|
||||||
|
|
||||||
// Auto-complete component
|
|
||||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
|
||||||
|
|
||||||
|
|
||||||
/* Spectre overrides / fixes */
|
|
||||||
|
|
||||||
// Fix up visited styles
|
|
||||||
a:visited {
|
|
||||||
color: $link-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited:hover {
|
|
||||||
color: $link-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link:visited:not(.btn-primary) {
|
|
||||||
color: $link-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link:visited:not(.btn-primary):hover {
|
|
||||||
color: $link-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
|
|
||||||
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
|
|
||||||
.btn {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make code work with light and dark theme
|
|
||||||
code {
|
|
||||||
color: $gray-color-dark;
|
|
||||||
background-color: $code-bg-color;
|
|
||||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove left padding from first pagination link
|
|
||||||
.pagination .page-item:first-child a {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override border color for tab block
|
|
||||||
.tab-block {
|
|
||||||
border-bottom: solid 1px $border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix padding for first menu item
|
|
||||||
ul.menu li:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form auto-complete menu
|
|
||||||
.form-autocomplete .menu {
|
|
||||||
.menu-item.selected > a, .menu-item > a:hover {
|
|
||||||
background: $secondary-color;
|
|
||||||
color: $primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-item, .group-item:hover {
|
|
||||||
color: $gray-color;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: none;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
// Add border to separate from background in dark mode
|
|
||||||
.modal-container {
|
|
||||||
border: solid 1px $border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix modal header to use default color
|
|
||||||
.modal-header {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customize modal animation
|
|
||||||
@keyframes fade-in {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-out {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.active .modal-container, .modal.active .modal-overlay {
|
|
||||||
animation: fade-in .15s ease 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
|
|
||||||
animation: fade-out .15s ease 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customize menu animation
|
|
||||||
.dropdown .menu {
|
|
||||||
animation: fade-in .15s ease 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal close button
|
|
||||||
.modal .modal-header button.close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: .85;
|
|
||||||
color: $gray-color-dark;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
|
||||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
|
||||||
// viewport size
|
|
||||||
@media screen and (max-width: 430px) {
|
|
||||||
.form-input {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
143
bookmarks/styles/theme-dark.css
Normal file
143
bookmarks/styles/theme-dark.css
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
@import "theme-light.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Color palette */
|
||||||
|
--contrast-5: hsla(241, 65%, 85%, 0.06);
|
||||||
|
--contrast-10: hsla(241, 60%, 80%, 0.14);
|
||||||
|
--contrast-20: hsla(241, 64%, 82%, 0.23);
|
||||||
|
--contrast-30: hsla(241, 69%, 84%, 0.32);
|
||||||
|
--contrast-40: hsla(241, 73%, 86%, 0.41);
|
||||||
|
--contrast-50: hsla(241, 78%, 88%, 0.5);
|
||||||
|
--contrast-60: hsla(241, 82%, 90%, 0.58);
|
||||||
|
--contrast-70: hsla(241, 87%, 92%, 0.69);
|
||||||
|
--contrast-80: hsla(241, 91%, 94%, 0.8);
|
||||||
|
--contrast-90: hsla(241, 96%, 96%, 0.9);
|
||||||
|
|
||||||
|
--primary-color: hsl(241, 75%, 64%);
|
||||||
|
--primary-color-highlight: hsl(241, 75%, 68%);
|
||||||
|
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
|
||||||
|
|
||||||
|
--alternative-color: hsl(179, 50%, 58%);
|
||||||
|
--alternative-color-dark: hsl(179, 80%, 75%);
|
||||||
|
|
||||||
|
--success-color: hsl(142, 76%, 36%);
|
||||||
|
--success-color-highlight: hsl(142, 76%, 40%);
|
||||||
|
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||||
|
|
||||||
|
--warning-color: hsl(38, 92%, 50%);
|
||||||
|
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||||
|
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||||
|
|
||||||
|
--error-color: hsl(0, 80%, 60%);
|
||||||
|
--error-color-highlight: hsl(0, 72%, 60%);
|
||||||
|
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||||
|
|
||||||
|
/* Core colors */
|
||||||
|
--text-color: var(--gray-300);
|
||||||
|
--secondary-text-color: var(--gray-400);
|
||||||
|
--tertiary-text-color: var(--gray-500);
|
||||||
|
--contrast-text-color: #fff;
|
||||||
|
--primary-text-color: hsl(241, 82%, 82%);
|
||||||
|
|
||||||
|
--link-color: var(--primary-text-color);
|
||||||
|
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
|
||||||
|
|
||||||
|
--icon-color: var(--text-color);
|
||||||
|
|
||||||
|
--border-color: var(--contrast-30);
|
||||||
|
--secondary-border-color: var(--contrast-20);
|
||||||
|
|
||||||
|
--body-color: hsl(241, 15%, 14%);
|
||||||
|
--body-color-contrast: var(--contrast-10);
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
--focus-outline: 2px solid hsl(241, 100%, 78%);
|
||||||
|
--focus-outline-offset: 2px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--box-shadow-xs: none;
|
||||||
|
--box-shadow: none;
|
||||||
|
--box-shadow-lg: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--input-bg-color: var(--contrast-5);
|
||||||
|
--input-disabled-bg-color: var(--contrast-30);
|
||||||
|
--input-text-color: var(--text-color);
|
||||||
|
--input-hint-color: var(--secondary-text-color);
|
||||||
|
--input-border-color: var(--border-color);
|
||||||
|
--input-placeholder-color: var(--tertiary-text-color);
|
||||||
|
--input-box-shadow: var(--box-shadow-xs);
|
||||||
|
|
||||||
|
--checkbox-bg-color: var(--contrast-10);
|
||||||
|
--checkbox-checked-bg-color: var(--primary-color);
|
||||||
|
--checkbox-disabled-bg-color: var(--contrast-30);
|
||||||
|
--checkbox-border-color: var(--border-color);
|
||||||
|
--checkbox-icon-color: #fff;
|
||||||
|
|
||||||
|
--switch-bg-color: var(--contrast-10);
|
||||||
|
--switch-border-color: var(--border-color);
|
||||||
|
--switch-toggle-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--btn-bg-color: var(--contrast-5);
|
||||||
|
--btn-hover-bg-color: var(--contrast-20);
|
||||||
|
--btn-border-color: var(--border-color);
|
||||||
|
--btn-text-color: var(--text-color);
|
||||||
|
--btn-icon-color: var(--icon-color);
|
||||||
|
--btn-font-weight: 400;
|
||||||
|
--btn-box-shadow: var(--box-shadow-xs);
|
||||||
|
|
||||||
|
--btn-primary-bg-color: var(--primary-color);
|
||||||
|
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||||
|
--btn-primary-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-success-bg-color: var(--success-color);
|
||||||
|
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||||
|
--btn-success-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-error-bg-color: var(--error-color);
|
||||||
|
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||||
|
--btn-error-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-link-text-color: var(--link-color);
|
||||||
|
--btn-link-hover-text-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
|
||||||
|
--modal-container-bg-color: hsl(241, 20%, 20%);
|
||||||
|
--modal-container-border-color: var(--contrast-30);
|
||||||
|
--modal-border-radius: var(--border-radius-lg);
|
||||||
|
--modal-box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--menu-bg-color: hsl(241, 20%, 20%);
|
||||||
|
--menu-border-color: var(--contrast-30);
|
||||||
|
--menu-border-radius: var(--border-radius);
|
||||||
|
--menu-box-shadow: none;
|
||||||
|
--menu-item-color: var(--text-color);
|
||||||
|
--menu-item-hover-color: var(--text-color);
|
||||||
|
--menu-item-bg-color: transparent;
|
||||||
|
--menu-item-hover-bg-color: var(--contrast-20);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--tab-color: var(--text-color);
|
||||||
|
--tab-hover-color: var(--primary-text-color);
|
||||||
|
--tab-active-color: var(--primary-text-color);
|
||||||
|
--tab-highlight-color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bookmark-title-color: var(--primary-text-color);
|
||||||
|
--bookmark-title-weight: 500;
|
||||||
|
--bookmark-description-color: var(--text-color);
|
||||||
|
--bookmark-description-weight: 400;
|
||||||
|
--bookmark-actions-color: var(--secondary-text-color);
|
||||||
|
--bookmark-actions-hover-color: var(--text-color);
|
||||||
|
--bookmark-actions-weight: 400;
|
||||||
|
--bulk-actions-bg-color: var(--contrast-5);
|
||||||
|
}
|
@@ -1,66 +0,0 @@
|
|||||||
// Import custom variables
|
|
||||||
@import "variables-dark";
|
|
||||||
|
|
||||||
// Import Spectre CSS lib
|
|
||||||
@import "spectre";
|
|
||||||
|
|
||||||
// Import style modules
|
|
||||||
@import "base";
|
|
||||||
@import "responsive";
|
|
||||||
@import "bookmark-details";
|
|
||||||
@import "bookmark-page";
|
|
||||||
@import "bookmark-form";
|
|
||||||
@import "settings";
|
|
||||||
@import "markdown";
|
|
||||||
@import "reader-mode";
|
|
||||||
|
|
||||||
/* Dark theme overrides */
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
.btn.btn-primary {
|
|
||||||
background: $dt-primary-button-color;
|
|
||||||
border-color: darken($dt-primary-button-color, 5%);
|
|
||||||
|
|
||||||
&:hover, &:active, &:focus {
|
|
||||||
background: darken($dt-primary-button-color, 5%);
|
|
||||||
border-color: darken($dt-primary-button-color, 10%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus ring
|
|
||||||
a:focus, .btn:focus {
|
|
||||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forms
|
|
||||||
.form-input:not(:placeholder-shown):invalid,
|
|
||||||
.form-input:not(:placeholder-shown):invalid:focus,
|
|
||||||
.has-error .form-input,
|
|
||||||
.form-input.is-error,
|
|
||||||
.has-error .form-select,
|
|
||||||
.form-select.is-error {
|
|
||||||
background: darken($error-color, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
|
||||||
background: $dt-primary-input-color;
|
|
||||||
border-color: $dt-primary-input-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
|
|
||||||
background: $light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-switch input:checked + .form-icon {
|
|
||||||
background: $dt-primary-input-color;
|
|
||||||
border-color: $dt-primary-input-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-radio input:checked + .form-icon::before {
|
|
||||||
background: $light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
.pagination .page-item.active a {
|
|
||||||
background: $dt-primary-button-color;
|
|
||||||
}
|
|
30
bookmarks/styles/theme-light.css
Normal file
30
bookmarks/styles/theme-light.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@import "theme/variables.css";
|
||||||
|
@import "theme/_normalize.css";
|
||||||
|
@import "theme/base.css";
|
||||||
|
@import "theme/typography.css";
|
||||||
|
@import "theme/asian.css";
|
||||||
|
@import "theme/tables.css";
|
||||||
|
@import "theme/buttons.css";
|
||||||
|
@import "theme/forms.css";
|
||||||
|
@import "theme/code.css";
|
||||||
|
@import "theme/dropdowns.css";
|
||||||
|
@import "theme/menus.css";
|
||||||
|
@import "theme/badges.css";
|
||||||
|
@import "theme/empty.css";
|
||||||
|
@import "theme/modals.css";
|
||||||
|
@import "theme/pagination.css";
|
||||||
|
@import "theme/tabs.css";
|
||||||
|
@import "theme/toasts.css";
|
||||||
|
@import "theme/autocomplete.css";
|
||||||
|
@import "theme/animations.css";
|
||||||
|
@import "theme/utilities.css";
|
||||||
|
|
||||||
|
@import "responsive.css";
|
||||||
|
@import "layout.css";
|
||||||
|
@import "components.css";
|
||||||
|
@import "bookmark-details.css";
|
||||||
|
@import "bookmark-form.css";
|
||||||
|
@import "bookmark-page.css";
|
||||||
|
@import "markdown.css";
|
||||||
|
@import "reader-mode.css";
|
||||||
|
@import "settings.css";
|
@@ -1,15 +0,0 @@
|
|||||||
// Import custom variables
|
|
||||||
@import "variables-light";
|
|
||||||
|
|
||||||
// Import Spectre CSS lib
|
|
||||||
@import "spectre";
|
|
||||||
|
|
||||||
// Import style modules
|
|
||||||
@import "base";
|
|
||||||
@import "responsive";
|
|
||||||
@import "bookmark-details";
|
|
||||||
@import "bookmark-page";
|
|
||||||
@import "bookmark-form";
|
|
||||||
@import "settings";
|
|
||||||
@import "markdown";
|
|
||||||
@import "reader-mode";
|
|
21
bookmarks/styles/theme/LICENSE
Normal file
21
bookmarks/styles/theme/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2016 - 2020 Yan Zhu
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
446
bookmarks/styles/theme/_normalize.css
Normal file
446
bookmarks/styles/theme/_normalize.css
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/* Manually forked from Normalize.css */
|
||||||
|
/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Change the default font family in all browsers (opinionated).
|
||||||
|
* 2. Correct the line height in all browsers.
|
||||||
|
* 3. Prevent adjustments of font size after orientation changes in
|
||||||
|
* IE on Windows Phone and in iOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Document
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: sans-serif; /* 1 */
|
||||||
|
-ms-text-size-adjust: 100%; /* 3 */
|
||||||
|
-webkit-text-size-adjust: 100%; /* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the margin in all browsers (opinionated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 9-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
article,
|
||||||
|
aside,
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
nav,
|
||||||
|
section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the font size and margin on `h1` elements within `section` and
|
||||||
|
* `article` contexts in Chrome, Firefox, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0.67em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grouping content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 9-.
|
||||||
|
* 1. Add the correct display in IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
figcaption,
|
||||||
|
figure,
|
||||||
|
main { /* 1 */
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct margin in IE 8 (removed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Add the correct box sizing in Firefox.
|
||||||
|
* 2. Show the overflow in Edge and IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box; /* 1 */
|
||||||
|
height: 0; /* 1 */
|
||||||
|
overflow: visible; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inheritance and scaling of font size in all browsers. (removed)
|
||||||
|
* 2. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Text-level semantics
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Remove the gray background on active links in IE 10.
|
||||||
|
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
|
||||||
|
*/
|
||||||
|
|
||||||
|
a {
|
||||||
|
background-color: transparent; /* 1 */
|
||||||
|
-webkit-text-decoration-skip: objects; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the outline on focused links when they are also active or hovered
|
||||||
|
* in all browsers (opinionated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
a:active,
|
||||||
|
a:hover {
|
||||||
|
outline-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify default styling of address.
|
||||||
|
*/
|
||||||
|
|
||||||
|
address {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Remove the bottom border in Firefox 39-.
|
||||||
|
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||||
|
* 2. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
pre,
|
||||||
|
samp {
|
||||||
|
font-family: var(--mono-font-family); /* 1 (changed) */
|
||||||
|
font-size: 1em; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font style in Android 4.3-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
dfn {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct background and color in IE 9-. (Removed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
font-weight: 400; /* (added) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||||
|
* all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Embedded content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 9-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
audio,
|
||||||
|
video {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in iOS 4-7.
|
||||||
|
*/
|
||||||
|
|
||||||
|
audio:not([controls]) {
|
||||||
|
display: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the border on images inside links in IE 10-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the overflow in IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
svg:not(:root) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Change the font styles in all browsers (opinionated).
|
||||||
|
* 2. Remove the margin in Firefox and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit; /* 1 (changed) */
|
||||||
|
font-size: inherit; /* 1 (changed) */
|
||||||
|
line-height: inherit; /* 1 (changed) */
|
||||||
|
margin: 0; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the overflow in IE.
|
||||||
|
* 1. Show the overflow in Edge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input { /* 1 */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||||
|
* 1. Remove the inheritance of text transform in Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
select { /* 1 */
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
|
||||||
|
* controls in Android 4.
|
||||||
|
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
html [type="button"], /* 1 */
|
||||||
|
[type="reset"],
|
||||||
|
[type="submit"] {
|
||||||
|
-webkit-appearance: button; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inner border and padding in Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button::-moz-focus-inner,
|
||||||
|
[type="button"]::-moz-focus-inner,
|
||||||
|
[type="reset"]::-moz-focus-inner,
|
||||||
|
[type="submit"]::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the focus styles unset by the previous rule (removed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the border, margin, and padding in all browsers (opinionated) (changed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the text wrapping in Edge and IE.
|
||||||
|
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||||
|
* 3. Remove the padding so developers are not caught out when they zero out
|
||||||
|
* `fieldset` elements in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
legend {
|
||||||
|
box-sizing: border-box; /* 1 */
|
||||||
|
color: inherit; /* 2 */
|
||||||
|
display: table; /* 1 */
|
||||||
|
max-width: 100%; /* 1 */
|
||||||
|
padding: 0; /* 3 */
|
||||||
|
white-space: normal; /* 1 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Add the correct display in IE 9-.
|
||||||
|
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
display: inline-block; /* 1 */
|
||||||
|
vertical-align: baseline; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the default vertical scrollbar in IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Add the correct box sizing in IE 10-.
|
||||||
|
* 2. Remove the padding in IE 10-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="checkbox"],
|
||||||
|
[type="radio"] {
|
||||||
|
box-sizing: border-box; /* 1 */
|
||||||
|
padding: 0; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="number"]::-webkit-inner-spin-button,
|
||||||
|
[type="number"]::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
* 2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="search"] {
|
||||||
|
-webkit-appearance: textfield; /* 1 */
|
||||||
|
outline-offset: -2px; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="search"]::-webkit-search-cancel-button,
|
||||||
|
[type="search"]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
* 2. Change font properties to `inherit` in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button; /* 1 */
|
||||||
|
font: inherit; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interactive
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the correct display in IE 9-.
|
||||||
|
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
details, /* 1 */
|
||||||
|
menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the correct display in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scripting
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 9-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
template {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 10-.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
38
bookmarks/styles/theme/animations.css
Normal file
38
bookmarks/styles/theme/animations.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/* Animations */
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(calc(-1 * var(--unit-8)));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
43
bookmarks/styles/theme/asian.css
Normal file
43
bookmarks/styles/theme/asian.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/* Optimized for East Asian CJK */
|
||||||
|
html:lang(zh),
|
||||||
|
html:lang(zh-Hans),
|
||||||
|
.lang-zh,
|
||||||
|
.lang-zh-hans {
|
||||||
|
font-family: var(--cjk-zh-hans-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:lang(zh-Hant),
|
||||||
|
.lang-zh-hant {
|
||||||
|
font-family: var(--cjk-zh-hant-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:lang(ja),
|
||||||
|
.lang-ja {
|
||||||
|
font-family: var(--cjk-jp-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:lang(ko),
|
||||||
|
.lang-ko {
|
||||||
|
font-family: var(--cjk-ko-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
:lang(zh),
|
||||||
|
:lang(ja),
|
||||||
|
.lang-cjk {
|
||||||
|
& ins,
|
||||||
|
& u {
|
||||||
|
border-bottom: var(--border-width) solid;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& del + del,
|
||||||
|
& del + s,
|
||||||
|
& ins + ins,
|
||||||
|
& ins + u,
|
||||||
|
& s + del,
|
||||||
|
& s + s,
|
||||||
|
& u + ins,
|
||||||
|
& u + u {
|
||||||
|
margin-left: .125em;
|
||||||
|
}
|
||||||
|
}
|
55
bookmarks/styles/theme/autocomplete.css
Normal file
55
bookmarks/styles/theme/autocomplete.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* Autocomplete */
|
||||||
|
.form-autocomplete {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& .form-autocomplete-input {
|
||||||
|
align-content: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
min-height: var(--unit-8);
|
||||||
|
padding: var(--unit-h);
|
||||||
|
background: var(--input-bg-color);
|
||||||
|
|
||||||
|
&.is-focused {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-input {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
display: inline-block;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
height: var(--unit-6);
|
||||||
|
line-height: var(--unit-4);
|
||||||
|
margin: var(--unit-h);
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .menu {
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& .menu-item.selected > a, & .menu-item > a:hover {
|
||||||
|
background: var(--menu-item-hover-bg-color);
|
||||||
|
color: var(--menu-item-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .group-item, & .group-item:hover {
|
||||||
|
color: var(--tertiary-text-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
64
bookmarks/styles/theme/badges.css
Normal file
64
bookmarks/styles/theme/badges.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&[data-badge],
|
||||||
|
&:not([data-badge]) {
|
||||||
|
&::after {
|
||||||
|
background: var(--primary-color);
|
||||||
|
background-clip: padding-box;
|
||||||
|
border-radius: .5rem;
|
||||||
|
box-shadow: 0 0 0 1px var(--body-color);
|
||||||
|
color: var(--contrast-text-color);
|
||||||
|
content: attr(data-badge);
|
||||||
|
display: inline-block;
|
||||||
|
transform: translate(-.05rem, -.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-badge] {
|
||||||
|
&::after {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: .9rem;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: .9rem;
|
||||||
|
padding: .1rem .2rem;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not([data-badge]),
|
||||||
|
&[data-badge=""] {
|
||||||
|
&::after {
|
||||||
|
height: 6px;
|
||||||
|
min-width: 6px;
|
||||||
|
padding: 0;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges for Buttons */
|
||||||
|
|
||||||
|
&.btn {
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges for Avatars */
|
||||||
|
|
||||||
|
&.avatar {
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 14.64%;
|
||||||
|
right: 14.64%;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
z-index: var(--zindex-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
bookmarks/styles/theme/base.css
Normal file
61
bookmarks/styles/theme/base.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* Base */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: var(--html-font-size);
|
||||||
|
line-height: var(--html-line-height);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--body-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: var(--body-font-family);
|
||||||
|
font-size: var(--font-size);
|
||||||
|
overflow-x: hidden;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link-color);
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: var(--focus-outline-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus,
|
||||||
|
a:hover,
|
||||||
|
a:active,
|
||||||
|
a.active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary:focus-visible {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: var(--focus-outline-offset);
|
||||||
|
}
|
257
bookmarks/styles/theme/buttons.css
Normal file
257
bookmarks/styles/theme/buttons.css
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/* Buttons */
|
||||||
|
:root {
|
||||||
|
--btn-bg-color: var(--body-color);
|
||||||
|
--btn-hover-bg-color: var(--gray-50);
|
||||||
|
--btn-border-color: var(--border-color);
|
||||||
|
--btn-text-color: var(--text-color);
|
||||||
|
--btn-icon-color: var(--icon-color);
|
||||||
|
--btn-font-weight: 400;
|
||||||
|
--btn-box-shadow: var(--box-shadow-xs);
|
||||||
|
|
||||||
|
--btn-primary-bg-color: var(--primary-color);
|
||||||
|
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||||
|
--btn-primary-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-success-bg-color: var(--success-color);
|
||||||
|
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||||
|
--btn-success-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-error-bg-color: var(--error-color);
|
||||||
|
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||||
|
--btn-error-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-link-text-color: var(--link-color);
|
||||||
|
--btn-link-hover-text-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--btn-bg-color);
|
||||||
|
border: var(--border-width) solid var(--btn-border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--btn-text-color);
|
||||||
|
font-weight: var(--btn-font-weight);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-size);
|
||||||
|
height: var(--control-size);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
outline: none;
|
||||||
|
padding: var(--control-padding-y) var(--control-padding-x);
|
||||||
|
box-shadow: var(--btn-box-shadow);
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, border 0.2s, box-shadow 0.2s, color 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: var(--focus-outline-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--btn-hover-bg-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Primary */
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--btn-primary-bg-color);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--btn-primary-text-color);
|
||||||
|
--btn-icon-color: var(--btn-primary-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--btn-primary-hover-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Colors */
|
||||||
|
|
||||||
|
&.btn-success {
|
||||||
|
background: var(--btn-success-bg-color);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--btn-success-text-color);
|
||||||
|
--btn-icon-color: var(--btn-success-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--btn-success-hover-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-error {
|
||||||
|
--btn-border-color: var(--error-color);
|
||||||
|
--btn-text-color: var(--error-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--btn-hover-bg-color: var(--error-color-shade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Link */
|
||||||
|
|
||||||
|
&.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--btn-link-text-color);
|
||||||
|
--btn-icon-color: var(--btn-link-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--btn-link-hover-text-color);
|
||||||
|
--btn-icon-color: var(--btn-link-hover-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Sizes */
|
||||||
|
|
||||||
|
&.btn-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: var(--control-size-sm);
|
||||||
|
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
height: var(--control-size-lg);
|
||||||
|
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Block */
|
||||||
|
|
||||||
|
&.btn-block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Action */
|
||||||
|
|
||||||
|
&.btn-action {
|
||||||
|
width: var(--control-size);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
|
||||||
|
&.btn-sm {
|
||||||
|
width: var(--control-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-lg {
|
||||||
|
width: var(--control-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Clear */
|
||||||
|
|
||||||
|
&.btn-clear {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: currentColor;
|
||||||
|
box-shadow: none;
|
||||||
|
height: var(--unit-5);
|
||||||
|
line-height: var(--unit-4);
|
||||||
|
margin-left: var(--unit-1);
|
||||||
|
margin-right: -2px;
|
||||||
|
opacity: 1;
|
||||||
|
padding: var(--unit-h);
|
||||||
|
text-decoration: none;
|
||||||
|
width: var(--unit-5);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "\2715";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider button */
|
||||||
|
|
||||||
|
&.btn-wide {
|
||||||
|
padding-left: var(--unit-6);
|
||||||
|
padding-right: var(--unit-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small icon button */
|
||||||
|
|
||||||
|
&.btn-sm.btn-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--unit-h);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button icons */
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
color: var(--btn-icon-color);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button groups */
|
||||||
|
.btn-group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
|
||||||
|
&:first-child:not(:last-child) {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child):not(:last-child) {
|
||||||
|
border-radius: 0;
|
||||||
|
margin-left: calc(-1 * var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child:not(:first-child) {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
margin-left: calc(-1 * var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
z-index: var(--zindex-0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-group-block {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
bookmarks/styles/theme/code.css
Normal file
30
bookmarks/styles/theme/code.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/* Code */
|
||||||
|
:root {
|
||||||
|
--code-bg-color: var(--body-color-contrast);
|
||||||
|
--code-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
line-height: 1.25;
|
||||||
|
padding: .1rem .2rem;
|
||||||
|
background: var(--code-bg-color);
|
||||||
|
color: var(--code-color);
|
||||||
|
font-size: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--code-bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& code {
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: var(--unit-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
36
bookmarks/styles/theme/dropdowns.css
Normal file
36
bookmarks/styles/theme/dropdowns.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/* Dropdown */
|
||||||
|
.dropdown {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
display: none;
|
||||||
|
left: 0;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropdown-right {
|
||||||
|
.menu {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .menu,
|
||||||
|
.dropdown-toggle:focus + .menu,
|
||||||
|
.menu:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix dropdown-toggle border radius in button groups */
|
||||||
|
.btn-group {
|
||||||
|
.dropdown-toggle:nth-last-child(2) {
|
||||||
|
border-bottom-right-radius: var(--border-radius);
|
||||||
|
border-top-right-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
bookmarks/styles/theme/empty.css
Normal file
21
bookmarks/styles/theme/empty.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* Empty states (or Blank slates) */
|
||||||
|
.empty {
|
||||||
|
background: var(--body-color-contrast);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--unit-16) var(--unit-8);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
margin-bottom: var(--layout-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title,
|
||||||
|
.empty-subtitle {
|
||||||
|
margin: var(--layout-spacing) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-action {
|
||||||
|
margin-top: var(--layout-spacing-lg);
|
||||||
|
}
|
||||||
|
}
|
515
bookmarks/styles/theme/forms.css
Normal file
515
bookmarks/styles/theme/forms.css
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
/* Forms */
|
||||||
|
:root {
|
||||||
|
--input-bg-color: var(--body-color);
|
||||||
|
--input-disabled-bg-color: var(--gray-100);
|
||||||
|
--input-text-color: var(--text-color);
|
||||||
|
--input-hint-color: var(--secondary-text-color);
|
||||||
|
--input-border-color: var(--border-color);
|
||||||
|
--input-placeholder-color: var(--tertiary-text-color);
|
||||||
|
--input-box-shadow: var(--box-shadow-xs);
|
||||||
|
|
||||||
|
--checkbox-bg-color: var(--body-color);
|
||||||
|
--checkbox-checked-bg-color: var(--primary-color);
|
||||||
|
--checkbox-disabled-bg-color: var(--gray-100);
|
||||||
|
--checkbox-border-color: var(--border-color);
|
||||||
|
--checkbox-icon-color: #fff;
|
||||||
|
|
||||||
|
--switch-bg-color: var(--gray-300);
|
||||||
|
--switch-border-color: var(--gray-400);
|
||||||
|
--switch-toggle-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: var(--unit-4);
|
||||||
|
}
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin-bottom: var(--layout-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--layout-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Label */
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
line-height: var(--line-height);
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary .form-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] summary .form-label {
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Input */
|
||||||
|
.form-input {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--input-bg-color);
|
||||||
|
background-image: none;
|
||||||
|
border: var(--border-width) solid var(--input-border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--input-box-shadow);
|
||||||
|
color: var(--input-text-color);
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size);
|
||||||
|
height: var(--control-size);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
max-width: 100%;
|
||||||
|
outline: none;
|
||||||
|
padding: var(--control-padding-y) var(--control-padding-x);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s, border 0.2s, color 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--input-placeholder-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input sizes */
|
||||||
|
|
||||||
|
&.input-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: var(--control-size-sm);
|
||||||
|
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.input-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
height: var(--control-size-lg);
|
||||||
|
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.input-inline {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input types */
|
||||||
|
|
||||||
|
&[type="file"] {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Textarea */
|
||||||
|
textarea.form-input {
|
||||||
|
&,
|
||||||
|
&.input-lg,
|
||||||
|
&.input-sm {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Input hint */
|
||||||
|
.form-input-hint {
|
||||||
|
color: var(--input-hint-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-top: var(--unit-1);
|
||||||
|
|
||||||
|
.has-success &,
|
||||||
|
.is-success + & {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-error &,
|
||||||
|
.is-error + & {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Select */
|
||||||
|
.form-select {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--input-bg-color);
|
||||||
|
border: var(--border-width) solid var(--input-border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--input-box-shadow);
|
||||||
|
color: var(--input-text-color);
|
||||||
|
font-size: var(--font-size);
|
||||||
|
height: var(--control-size);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
outline: none;
|
||||||
|
padding: var(--control-padding-y) var(--control-padding-x);
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select sizes */
|
||||||
|
|
||||||
|
&.select-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: var(--control-size-sm);
|
||||||
|
padding: var(--control-padding-y-sm) calc(var(--control-icon-size) + var(--control-padding-x-sm)) var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.select-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
height: var(--control-size-lg);
|
||||||
|
padding: var(--control-padding-y-lg) calc(var(--control-icon-size) + var(--control-padding-x-lg)) var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multiple select */
|
||||||
|
|
||||||
|
&[size],
|
||||||
|
&[multiple] {
|
||||||
|
height: auto;
|
||||||
|
padding: var(--control-padding-y) var(--control-padding-x);
|
||||||
|
|
||||||
|
& option {
|
||||||
|
padding: var(--unit-h) var(--unit-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not([multiple]):not([size]) {
|
||||||
|
background: var(--input-bg-color) url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center / .4rem .5rem;
|
||||||
|
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Checkbox and Radio */
|
||||||
|
.form-checkbox,
|
||||||
|
.form-radio,
|
||||||
|
.form-switch {
|
||||||
|
display: block;
|
||||||
|
line-height: var(--line-height);
|
||||||
|
margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;
|
||||||
|
min-height: var(--control-size-sm);
|
||||||
|
padding: calc((var(--control-size-sm) - var(--line-height)) / 2) var(--control-padding-x) calc((var(--control-size-sm) - var(--line-height)) / 2) calc(var(--control-icon-size) + var(--control-padding-x));
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input {
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
|
||||||
|
&:focus-visible + .form-icon {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: var(--focus-outline-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked + .form-icon {
|
||||||
|
background: var(--checkbox-checked-bg-color);
|
||||||
|
border-color: var(--checkbox-checked-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-icon {
|
||||||
|
border: var(--border-width) solid var(--checkbox-border-color);
|
||||||
|
box-shadow: var(--input-box-shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
transition: background .2s, border .2s, color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input checkbox, radio, and switch sizes */
|
||||||
|
|
||||||
|
&.input-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.input-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox,
|
||||||
|
.form-radio {
|
||||||
|
.form-icon {
|
||||||
|
background: var(--checkbox-bg-color);
|
||||||
|
height: var(--control-icon-size);
|
||||||
|
left: 0;
|
||||||
|
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||||
|
width: var(--control-icon-size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox {
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.form-icon {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
&:checked + .form-icon {
|
||||||
|
&::before {
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: var(--border-width-lg) solid var(--checkbox-icon-color);
|
||||||
|
border-left-width: 0;
|
||||||
|
border-top-width: 0;
|
||||||
|
content: "";
|
||||||
|
height: 9px;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -3px;
|
||||||
|
margin-top: -6px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:indeterminate + .form-icon {
|
||||||
|
background: var(--checkbox-checked-bg-color);
|
||||||
|
border-color: var(--checkbox-checked-bg-color);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: var(--checkbox-icon-color);
|
||||||
|
content: "";
|
||||||
|
height: 2px;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -5px;
|
||||||
|
margin-top: -1px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-radio {
|
||||||
|
.form-icon {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
&:checked + .form-icon {
|
||||||
|
&::before {
|
||||||
|
background: var(--checkbox-icon-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: "";
|
||||||
|
height: 6px;
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element: Switch */
|
||||||
|
.form-switch {
|
||||||
|
padding-left: calc(var(--unit-8) + var(--control-padding-x));
|
||||||
|
|
||||||
|
.form-icon {
|
||||||
|
background: var(--switch-bg-color);
|
||||||
|
background-clip: padding-box;
|
||||||
|
border-color: var(--switch-border-color);
|
||||||
|
border-radius: calc(var(--unit-2) + var(--border-width));
|
||||||
|
height: calc(var(--unit-4) + var(--border-width) * 2);
|
||||||
|
left: 0;
|
||||||
|
top: calc((var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width));
|
||||||
|
width: var(--unit-8);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: var(--switch-toggle-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: var(--unit-4);
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transition: background .2s, border .2s, color .2s, left .2s;
|
||||||
|
width: var(--unit-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
&:checked + .form-icon {
|
||||||
|
&::before {
|
||||||
|
left: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Icons */
|
||||||
|
.has-icon-left,
|
||||||
|
.has-icon-right {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.form-icon {
|
||||||
|
height: var(--control-icon-size);
|
||||||
|
margin: 0 var(--control-padding-y);
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: var(--control-icon-size);
|
||||||
|
z-index: calc(var(--zindex-0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-icon-left {
|
||||||
|
& .form-icon {
|
||||||
|
left: var(--border-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-input {
|
||||||
|
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-icon-right {
|
||||||
|
& .form-icon {
|
||||||
|
right: var(--border-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-input {
|
||||||
|
padding-right: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Form element: Input groups */
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.input-group-addon {
|
||||||
|
background: var(--body-color);
|
||||||
|
border: var(--border-width) solid var(--input-border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
padding: var(--control-padding-y) var(--control-padding-x);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.addon-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.addon-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 1%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-btn {
|
||||||
|
z-index: var(--zindex-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.input-group-addon,
|
||||||
|
.input-group-btn {
|
||||||
|
&:first-child:not(:last-child) {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child):not(:last-child) {
|
||||||
|
border-radius: 0;
|
||||||
|
margin-left: calc(-1 * var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child:not(:first-child) {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
margin-left: calc(-1 * var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
z-index: calc(var(--zindex-0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.input-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form validation states */
|
||||||
|
.form-input,
|
||||||
|
.form-select {
|
||||||
|
.has-success &,
|
||||||
|
&.is-success {
|
||||||
|
background: var(--success-color-shade);
|
||||||
|
border-color: var(--success-color);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline-color: var(--success-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-error &,
|
||||||
|
&.is-error {
|
||||||
|
background: var(--error-color-shade);
|
||||||
|
border-color: var(--error-color);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline-color: var(--error-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form disabled and readonly */
|
||||||
|
.form-input,
|
||||||
|
.form-select {
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
background-color: var(--input-disabled-bg-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
& + .form-icon {
|
||||||
|
background: var(--checkbox-disabled-bg-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase input font size on small viewports to prevent zooming on focus the input */
|
||||||
|
/* on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max */
|
||||||
|
/* viewport size */
|
||||||
|
@media screen and (max-width: 430px) {
|
||||||
|
.form-input {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
89
bookmarks/styles/theme/menus.css
Normal file
89
bookmarks/styles/theme/menus.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
:root {
|
||||||
|
--menu-bg-color: var(--body-color);
|
||||||
|
--menu-border-color: var(--gray-200);
|
||||||
|
--menu-border-radius: var(--border-radius);
|
||||||
|
--menu-box-shadow: var(--box-shadow);
|
||||||
|
--menu-item-color: var(--text-color);
|
||||||
|
--menu-item-hover-color: var(--primary-text-color);
|
||||||
|
--menu-item-bg-color: transparent;
|
||||||
|
--menu-item-hover-bg-color: var(--primary-color-shade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menus */
|
||||||
|
.menu {
|
||||||
|
background: var(--menu-bg-color);
|
||||||
|
border: solid 1px var(--menu-border-color);
|
||||||
|
border-radius: var(--menu-border-radius);
|
||||||
|
box-shadow: var(--menu-box-shadow);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
min-width: var(--control-width-xs);
|
||||||
|
transform: translateY(var(--layout-spacing-sm));
|
||||||
|
z-index: var(--zindex-3);
|
||||||
|
|
||||||
|
&.menu-nav {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0 var(--unit-4);
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
padding-top: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
padding-bottom: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > a, .btn.btn-link {
|
||||||
|
border-radius: var(--menu-border-radius);
|
||||||
|
color: var(--menu-item-color);
|
||||||
|
background: var(--menu-item-bg-color);
|
||||||
|
display: block;
|
||||||
|
margin: 0 calc(-1 * var(--unit-2));
|
||||||
|
padding: var(--unit-1) var(--unit-2);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
background: var(--menu-item-hover-bg-color);
|
||||||
|
color: var(--menu-item-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox,
|
||||||
|
.form-radio,
|
||||||
|
.form-switch {
|
||||||
|
margin: var(--unit-h) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + .menu-item {
|
||||||
|
margin-top: var(--unit-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .menu-badge {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-right: var(--unit-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .divider {
|
||||||
|
border-bottom: solid 1px var(--secondary-border-color);
|
||||||
|
margin: var(--unit-2) 0;
|
||||||
|
}
|
||||||
|
}
|
93
bookmarks/styles/theme/modals.css
Normal file
93
bookmarks/styles/theme/modals.css
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/* Modals */
|
||||||
|
:root {
|
||||||
|
--modal-overlay-bg-color: rgba(243, 244, 246, 0.6);
|
||||||
|
--modal-container-bg-color: var(--body-color);
|
||||||
|
--modal-container-border-color: var(--gray-200);
|
||||||
|
--modal-border-radius: var(--border-radius-lg);
|
||||||
|
--modal-box-shadow: var(--box-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
align-items: center;
|
||||||
|
bottom: 0;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--layout-spacing);
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
&:target,
|
||||||
|
&.active {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
z-index: var(--zindex-4);
|
||||||
|
|
||||||
|
& .modal-overlay {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
background: var(--modal-overlay-bg-color);
|
||||||
|
bottom: 0;
|
||||||
|
cursor: default;
|
||||||
|
display: block;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .modal-container {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
z-index: var(--zindex-0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active.closing {
|
||||||
|
& .modal-overlay, & .modal-container {
|
||||||
|
animation: fade-out .15s ease 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: var(--modal-container-bg-color);
|
||||||
|
border: solid 1px var(--modal-container-border-color);
|
||||||
|
border-radius: var(--modal-border-radius);
|
||||||
|
box-shadow: var(--modal-box-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
max-height: 75vh;
|
||||||
|
max-width: var(--control-width-md);
|
||||||
|
padding: var(--unit-6);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& .modal-header {
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
& button.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .85;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .modal-footer {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
61
bookmarks/styles/theme/pagination.css
Normal file
61
bookmarks/styles/theme/pagination.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--unit-1) 0;
|
||||||
|
padding: var(--unit-1) 0;
|
||||||
|
|
||||||
|
& .page-item {
|
||||||
|
margin: var(--unit-1) var(--unit-o);
|
||||||
|
|
||||||
|
& span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--unit-1) var(--unit-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--unit-1) var(--unit-2);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
& a {
|
||||||
|
cursor: default;
|
||||||
|
opacity: .5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
& a {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--contrast-text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.page-prev,
|
||||||
|
&.page-next {
|
||||||
|
flex: 1 0 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.page-next {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .page-item-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .page-item-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
bookmarks/styles/theme/tables.css
Normal file
26
bookmarks/styles/theme/tables.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
/* Scrollable tables */
|
||||||
|
|
||||||
|
&.table-scroll {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
& td,
|
||||||
|
& th {
|
||||||
|
border-bottom: var(--border-width) solid var(--border-color);
|
||||||
|
padding: var(--unit-3) var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& th {
|
||||||
|
border-bottom-width: var(--border-width-lg);
|
||||||
|
}
|
||||||
|
}
|
75
bookmarks/styles/theme/tabs.css
Normal file
75
bookmarks/styles/theme/tabs.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* Tabs */
|
||||||
|
:root {
|
||||||
|
--tab-color: var(--text-color);
|
||||||
|
--tab-hover-color: var(--primary-text-color);
|
||||||
|
--tab-active-color: var(--primary-text-color);
|
||||||
|
--tab-highlight-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-width) solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
|
||||||
|
|
||||||
|
& .tab-item {
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
& a {
|
||||||
|
border-bottom: var(--border-width-lg) solid transparent;
|
||||||
|
color: var(--tab-color);
|
||||||
|
display: block;
|
||||||
|
margin: 0 var(--unit-2) 0 0;
|
||||||
|
padding: var(--unit-2) var(--unit-1) calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
color: var(--tab-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active a,
|
||||||
|
& a.active {
|
||||||
|
border-bottom-color: var(--tab-highlight-color);
|
||||||
|
color: var(--tab-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tab-action {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .btn-clear {
|
||||||
|
margin-top: calc(-1 * var(--unit-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tab-block {
|
||||||
|
& .tab-item {
|
||||||
|
flex: 1 0 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& a {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .badge {
|
||||||
|
&[data-badge]::after {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--unit-h);
|
||||||
|
top: var(--unit-h);
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.tab-block) {
|
||||||
|
& .badge {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
bookmarks/styles/theme/toasts.css
Normal file
35
bookmarks/styles/theme/toasts.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/* Toasts */
|
||||||
|
.toast {
|
||||||
|
background: var(--gray-600);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--contrast-text-color);
|
||||||
|
display: block;
|
||||||
|
padding: var(--layout-spacing);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.toast-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-success {
|
||||||
|
background: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-warning {
|
||||||
|
background: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-error {
|
||||||
|
background: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear {
|
||||||
|
margin: var(--unit-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
117
bookmarks/styles/theme/typography.css
Normal file
117
bookmarks/styles/theme/typography.css
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/* Typography */
|
||||||
|
/* Headings */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.h1,
|
||||||
|
.h2,
|
||||||
|
.h3,
|
||||||
|
.h4,
|
||||||
|
.h5,
|
||||||
|
.h6 {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
.h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
h2,
|
||||||
|
.h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
h3,
|
||||||
|
.h3 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
h4,
|
||||||
|
.h4 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
h5,
|
||||||
|
.h5 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
h6,
|
||||||
|
.h6 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraphs */
|
||||||
|
p {
|
||||||
|
margin: 0 0 var(--line-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Semantic text elements */
|
||||||
|
a,
|
||||||
|
ins,
|
||||||
|
u {
|
||||||
|
text-decoration-skip-ink: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
border-bottom: var(--border-width) dotted;
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquote */
|
||||||
|
blockquote {
|
||||||
|
border-left: var(--border-width-lg) solid var(--border-color);
|
||||||
|
margin-left: 0;
|
||||||
|
padding: var(--unit-2) var(--unit-4);
|
||||||
|
|
||||||
|
& p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
& ul,
|
||||||
|
& ol {
|
||||||
|
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
& li {
|
||||||
|
margin-top: var(--unit-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: disc inside;
|
||||||
|
|
||||||
|
& ul {
|
||||||
|
list-style-type: circle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style: decimal inside;
|
||||||
|
|
||||||
|
& ol {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
& dt {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
& dd {
|
||||||
|
margin: var(--unit-1) 0 var(--unit-4) 0;
|
||||||
|
}
|
||||||
|
}
|
296
bookmarks/styles/theme/utilities.css
Normal file
296
bookmarks/styles/theme/utilities.css
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/* Colors */
|
||||||
|
.text-primary {
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tertiary {
|
||||||
|
color: var(--tertiary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-color {
|
||||||
|
color: var(--icon-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.d-block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-inline-block {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-none,
|
||||||
|
.d-hide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-visible {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-hide {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: transparent;
|
||||||
|
font-size: 0;
|
||||||
|
line-height: 0;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-assistive {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
color: transparent !important;
|
||||||
|
min-height: var(--unit-4);
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
animation: loading 500ms infinite linear;
|
||||||
|
background: transparent;
|
||||||
|
border: var(--border-width-lg) solid var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-top-color: transparent;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: var(--unit-4);
|
||||||
|
left: 50%;
|
||||||
|
margin-left: calc(-1 * var(--unit-2));
|
||||||
|
margin-top: calc(-1 * var(--unit-2));
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: var(--unit-4);
|
||||||
|
z-index: var(--zindex-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading-lg {
|
||||||
|
min-height: var(--unit-10);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
height: var(--unit-8);
|
||||||
|
margin-left: calc(-1 * var(--unit-4));
|
||||||
|
margin-top: calc(-1 * var(--unit-4));
|
||||||
|
width: var(--unit-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position */
|
||||||
|
.m-0 {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-0 {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-0 {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-0 {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-0 {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-0 {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-1 {
|
||||||
|
margin: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-1 {
|
||||||
|
margin-bottom: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-1 {
|
||||||
|
margin-left: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-1 {
|
||||||
|
margin-right: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-1 {
|
||||||
|
margin-left: var(--unit-1) !important;
|
||||||
|
margin-right: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-1 {
|
||||||
|
margin-bottom: var(--unit-1) !important;
|
||||||
|
margin-top: var(--unit-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-2 {
|
||||||
|
margin: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-2 {
|
||||||
|
margin-right: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-2 {
|
||||||
|
margin-left: var(--unit-2) !important;
|
||||||
|
margin-right: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-2 {
|
||||||
|
margin-bottom: var(--unit-2) !important;
|
||||||
|
margin-top: var(--unit-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-4 {
|
||||||
|
margin: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-4 {
|
||||||
|
margin-left: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-4 {
|
||||||
|
margin-right: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-4 {
|
||||||
|
margin-left: var(--unit-4) !important;
|
||||||
|
margin-right: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-4 {
|
||||||
|
margin-bottom: var(--unit-4) !important;
|
||||||
|
margin-top: var(--unit-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
.text-normal {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-large {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-small {
|
||||||
|
font-size: .9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tiny {
|
||||||
|
font-size: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex */
|
||||||
|
.align-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
135
bookmarks/styles/theme/variables.css
Normal file
135
bookmarks/styles/theme/variables.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
:root {
|
||||||
|
/* Color palette */
|
||||||
|
--gray-50: rgb(249, 250, 251);
|
||||||
|
--gray-100: rgb(243, 244, 246);
|
||||||
|
--gray-200: rgb(229, 231, 235);
|
||||||
|
--gray-300: rgb(209, 213, 219);
|
||||||
|
--gray-400: rgb(156, 163, 175);
|
||||||
|
--gray-500: rgb(107, 114, 128);
|
||||||
|
--gray-600: rgb(75, 85, 99);
|
||||||
|
--gray-700: rgb(55, 65, 81);
|
||||||
|
--gray-800: rgb(31, 41, 55);
|
||||||
|
--gray-900: rgb(17, 24, 39);
|
||||||
|
|
||||||
|
--primary-color: hsl(241, 63%, 59%);
|
||||||
|
--primary-color-highlight: hsl(241, 63%, 64%);
|
||||||
|
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
|
||||||
|
|
||||||
|
--alternative-color: hsl(179, 94%, 29%);
|
||||||
|
--alternative-color-dark: hsl(179, 94%, 22%);
|
||||||
|
|
||||||
|
--success-color: hsl(142, 76%, 36%);
|
||||||
|
--success-color-highlight: hsl(142, 76%, 40%);
|
||||||
|
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||||
|
|
||||||
|
--warning-color: hsl(38, 92%, 50%);
|
||||||
|
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||||
|
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||||
|
|
||||||
|
--error-color: hsl(0, 72%, 51%);
|
||||||
|
--error-color-highlight: hsl(0, 72%, 60%);
|
||||||
|
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||||
|
|
||||||
|
/* Core colors */
|
||||||
|
--text-color: var(--gray-700);
|
||||||
|
--secondary-text-color: var(--gray-500);
|
||||||
|
--tertiary-text-color: var(--gray-500);
|
||||||
|
--contrast-text-color: #fff;
|
||||||
|
--primary-text-color: hsl(241, 63%, 55%);
|
||||||
|
|
||||||
|
--link-color: var(--primary-text-color);
|
||||||
|
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
|
||||||
|
|
||||||
|
--icon-color: var(--gray-500);
|
||||||
|
|
||||||
|
--border-color: var(--gray-300);
|
||||||
|
--secondary-border-color: var(--gray-200);
|
||||||
|
|
||||||
|
--body-color: #fff;
|
||||||
|
--body-color-contrast: var(--gray-100);
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||||
|
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||||
|
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||||
|
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||||
|
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC", "Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||||
|
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
|
||||||
|
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
|
||||||
|
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||||
|
|
||||||
|
/* Unit sizes */
|
||||||
|
--unit-o: 0.05rem;
|
||||||
|
--unit-h: 0.1rem;
|
||||||
|
--unit-1: 0.2rem;
|
||||||
|
--unit-2: 0.4rem;
|
||||||
|
--unit-3: 0.6rem;
|
||||||
|
--unit-4: 0.8rem;
|
||||||
|
--unit-5: 1rem;
|
||||||
|
--unit-6: 1.2rem;
|
||||||
|
--unit-7: 1.4rem;
|
||||||
|
--unit-8: 1.6rem;
|
||||||
|
--unit-9: 1.8rem;
|
||||||
|
--unit-10: 2rem;
|
||||||
|
--unit-12: 2.4rem;
|
||||||
|
--unit-16: 3.2rem;
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--html-font-size: 20px;
|
||||||
|
--html-line-height: 1.5;
|
||||||
|
--font-size: 0.7rem;
|
||||||
|
--font-size-sm: 0.65rem;
|
||||||
|
--font-size-lg: 0.8rem;
|
||||||
|
--line-height: 1rem;
|
||||||
|
|
||||||
|
/* Sizes */
|
||||||
|
--layout-spacing: var(--unit-2);
|
||||||
|
--layout-spacing-sm: var(--unit-1);
|
||||||
|
--layout-spacing-lg: var(--unit-4);
|
||||||
|
--border-radius: var(--unit-1);
|
||||||
|
--border-radius-lg: var(--unit-2);
|
||||||
|
--border-width: var(--unit-o);
|
||||||
|
--border-width-lg: var(--unit-h);
|
||||||
|
--control-size: var(--unit-8);
|
||||||
|
--control-size-sm: var(--unit-6);
|
||||||
|
--control-size-lg: var(--unit-9);
|
||||||
|
--control-padding-x: var(--unit-2);
|
||||||
|
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
|
||||||
|
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
|
||||||
|
--control-padding-y: calc((var(--control-size) - var(--line-height)) / 2 - var(--border-width));
|
||||||
|
--control-padding-y-sm: calc((var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width));
|
||||||
|
--control-padding-y-lg: calc((var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width));
|
||||||
|
--control-icon-size: 0.8rem;
|
||||||
|
|
||||||
|
--control-width-xs: 180px;
|
||||||
|
--control-width-sm: 320px;
|
||||||
|
--control-width-md: 640px;
|
||||||
|
--control-width-lg: 960px;
|
||||||
|
--control-width-xl: 1280px;
|
||||||
|
|
||||||
|
/* Responsive breakpoints */
|
||||||
|
--size-xs: 480px;
|
||||||
|
--size-sm: 600px;
|
||||||
|
--size-md: 840px;
|
||||||
|
--size-lg: 960px;
|
||||||
|
--size-xl: 1280px;
|
||||||
|
--size-2x: 1440px;
|
||||||
|
|
||||||
|
--responsive-breakpoint: var(--size-xs);
|
||||||
|
|
||||||
|
/* Z-index */
|
||||||
|
--zindex-0: 1;
|
||||||
|
--zindex-1: 100;
|
||||||
|
--zindex-2: 200;
|
||||||
|
--zindex-3: 300;
|
||||||
|
--zindex-4: 400;
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
--focus-outline: 2px solid var(--primary-color);
|
||||||
|
--focus-outline-offset: 2px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||||
|
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
@@ -1,32 +0,0 @@
|
|||||||
$body-bg: #161822 !default;
|
|
||||||
$bg-color: lighten($body-bg, 5%) !default;
|
|
||||||
$bg-color-light: lighten($body-bg, 5%) !default;
|
|
||||||
|
|
||||||
$border-color: #4C4E53 !default;
|
|
||||||
$border-color-dark: $border-color !default;
|
|
||||||
|
|
||||||
$body-font-color: #b5bec8 !default;
|
|
||||||
$light-color: #fafafa !default;
|
|
||||||
|
|
||||||
$gray-color: #7f879b !default;
|
|
||||||
$gray-color-dark: lighten($gray-color, 20%) !default;
|
|
||||||
|
|
||||||
$primary-color: #a8b1ff !default;
|
|
||||||
$primary-color-dark: saturate($primary-color, 5%) !default;
|
|
||||||
$secondary-color: lighten($body-bg, 10%) !default;
|
|
||||||
|
|
||||||
$link-color: $primary-color !default;
|
|
||||||
$link-color-dark: darken($link-color, 5%) !default;
|
|
||||||
$link-color-light: $link-color !default;
|
|
||||||
|
|
||||||
$secondary-link-color: rgba(168, 177, 255, 0.73);
|
|
||||||
|
|
||||||
$alternative-color: #59bdb9;
|
|
||||||
$alternative-color-dark: #73f1eb;
|
|
||||||
|
|
||||||
$code-bg-color: rgba(255, 255, 255, 0.1);
|
|
||||||
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
|
||||||
|
|
||||||
/* Dark theme specific */
|
|
||||||
$dt-primary-input-color: #5C68E7 !default;
|
|
||||||
$dt-primary-button-color: #5761cb !default;
|
|
@@ -1,7 +0,0 @@
|
|||||||
$alternative-color: #05a6a3;
|
|
||||||
$alternative-color-dark: darken($alternative-color, 5%);
|
|
||||||
|
|
||||||
$secondary-link-color: rgba(87, 85, 217, 0.64);
|
|
||||||
|
|
||||||
$code-bg-color: rgba(0, 0, 0, 0.05);
|
|
||||||
$code-shadow-color: rgba(0, 0, 0, 0.15);
|
|
@@ -10,142 +10,144 @@
|
|||||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||||
{% for bookmark_item in bookmark_list.items %}
|
{% for bookmark_item in bookmark_list.items %}
|
||||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||||
<div class="title">
|
<div class="content">
|
||||||
<label class="form-checkbox bulk-edit-checkbox">
|
<div class="title">
|
||||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
<label class="form-checkbox bulk-edit-checkbox">
|
||||||
<i class="form-icon"></i>
|
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||||
</label>
|
<i class="form-icon"></i>
|
||||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
</label>
|
||||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||||
{% endif %}
|
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
{% endif %}
|
||||||
<span>{{ bookmark_item.title }}</span>
|
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||||
</a>
|
<span>{{ bookmark_item.title }}</span>
|
||||||
</div>
|
|
||||||
{% if bookmark_list.show_url %}
|
|
||||||
<div class="url-path truncate">
|
|
||||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
|
||||||
class="url-display">
|
|
||||||
{{ bookmark_item.url }}
|
|
||||||
</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"
|
||||||
|
class="url-display">
|
||||||
|
{{ bookmark_item.url }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_list.description_display == 'inline' %}
|
||||||
|
<div class="description inline truncate">
|
||||||
|
{% if bookmark_item.tag_names %}
|
||||||
|
<span class="tags">
|
||||||
|
{% for tag_name in bookmark_item.tag_names %}
|
||||||
|
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||||
|
{% if bookmark_item.description %}
|
||||||
|
<span>{{ bookmark_item.description }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if bookmark_item.description %}
|
||||||
|
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||||
|
{% endif %}
|
||||||
{% if bookmark_item.tag_names %}
|
{% if bookmark_item.tag_names %}
|
||||||
<span class="tags">
|
<div 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 %}
|
||||||
|
</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 ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||||
|
ld-on="click" ld-target="body|append"
|
||||||
|
data-turbo-prefetch="false"
|
||||||
|
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_item.is_editable %}
|
||||||
|
{# Bookmark owner actions #}
|
||||||
|
{% if bookmark_list.show_edit_action %}
|
||||||
|
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_list.show_archive_action %}
|
||||||
|
{% if bookmark_item.is_archived %}
|
||||||
|
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Unarchive
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Archive
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_list.show_remove_action %}
|
||||||
|
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Remove
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{# Shared bookmark actions #}
|
||||||
|
<span>Shared by
|
||||||
|
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
{% if bookmark_item.has_extra_actions %}
|
||||||
{% if bookmark_item.description %}
|
<div class="extra-actions">
|
||||||
<span>{{ bookmark_item.description }}</span>
|
<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>
|
||||||
{% 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 bg-gray text-gray-dark">
|
|
||||||
<div class="markdown">
|
|
||||||
{% markdown bookmark_item.notes %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="actions text-gray">
|
|
||||||
{% 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 ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
|
||||||
ld-on="click" ld-target="body|append"
|
|
||||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if bookmark_item.is_editable %}
|
|
||||||
{# Bookmark owner actions #}
|
|
||||||
{% if bookmark_list.show_edit_action %}
|
|
||||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if bookmark_list.show_archive_action %}
|
|
||||||
{% if bookmark_item.is_archived %}
|
|
||||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Unarchive
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Archive
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if bookmark_list.show_remove_action %}
|
|
||||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Remove
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% 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 %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
|
||||||
|
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
{% load shared %}
|
{% load shared %}
|
||||||
{% htmlmin %}
|
{% htmlmin %}
|
||||||
<div class="bulk-edit-bar">
|
<div class="bulk-edit-bar">
|
||||||
<div class="bulk-edit-actions bg-gray">
|
<div class="bulk-edit-actions">
|
||||||
<label class="form-checkbox bulk-edit-checkbox all">
|
<label class="form-checkbox bulk-edit-checkbox all">
|
||||||
<input type="checkbox">
|
<input type="checkbox">
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
@@ -27,7 +27,9 @@
|
|||||||
<input ld-tag-autocomplete variant="small"
|
<input ld-tag-autocomplete variant="small"
|
||||||
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
|
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
|
||||||
</div>
|
</div>
|
||||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
|
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">
|
||||||
|
<span>Execute</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<label class="form-checkbox select-across d-none">
|
<label class="form-checkbox select-across d-none">
|
||||||
<input type="checkbox" name="bulk_select_across">
|
<input type="checkbox" name="bulk_select_across">
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
|
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24" fill="none"
|
||||||
height="20px">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
|
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||||
|
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||||
|
<path d="M16 5l3 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="left-actions">
|
<div class="left-actions">
|
||||||
<a class="btn"
|
<a class="btn btn-wide"
|
||||||
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-actions">
|
<div class="right-actions">
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
method="post">
|
method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
|
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
|
||||||
class="btn btn-link text-error">
|
class="btn btn-error btn-wide">
|
||||||
Delete...
|
Delete...
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
@@ -36,11 +36,11 @@
|
|||||||
|
|
||||||
{% if details.is_editable %}
|
{% if details.is_editable %}
|
||||||
<div class="assets-actions">
|
<div class="assets-actions">
|
||||||
<button type="submit" name="create_snapshot" class="btn btn-link"
|
<button type="submit" name="create_snapshot" class="btn btn-sm"
|
||||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||||
</button>
|
</button>
|
||||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
|
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
|
||||||
class="btn btn-link">Upload file
|
class="btn btn-sm">Upload file
|
||||||
</button>
|
</button>
|
||||||
<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>
|
||||||
|
@@ -23,8 +23,8 @@
|
|||||||
<span>Reader mode</span>
|
<span>Reader mode</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.bookmark.web_archive_snapshot_url %}
|
{% if details.web_archive_snapshot_url %}
|
||||||
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
|
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
|
||||||
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" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||||
|
<div class="preview-image">
|
||||||
|
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||||
{% if details.is_editable %}
|
{% if details.is_editable %}
|
||||||
<div class="status col-2">
|
<div class="status col-2">
|
||||||
|
@@ -2,13 +2,15 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="content-area">
|
<div class="bookmarks-form-page">
|
||||||
<div class="content-area-header">
|
<section class="content-area">
|
||||||
<h2>Edit bookmark</h2>
|
<div class="content-area-header">
|
||||||
</div>
|
<h2>Edit bookmark</h2>
|
||||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
</div>
|
||||||
class="width-50 width-md-100" novalidate>
|
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||||
{% bookmark_form form return_url bookmark_id %}
|
novalidate>
|
||||||
</form>
|
{% bookmark_form form return_url bookmark_id %}
|
||||||
</section>
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -23,10 +23,10 @@
|
|||||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||||
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
||||||
exist it will be
|
If a tag does not exist it will be automatically created.
|
||||||
automatically created.
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-input-hint auto-tags"></div>
|
||||||
{{ form.tag_string.errors }}
|
{{ form.tag_string.errors }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group has-icon-right">
|
<div class="form-group has-icon-right">
|
||||||
@@ -34,14 +34,15 @@
|
|||||||
<div class="has-icon-right">
|
<div class="has-icon-right">
|
||||||
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||||
<i class="form-icon loading"></i>
|
<i class="form-icon loading"></i>
|
||||||
<a class="btn btn-link form-icon" title="Edit title from website">
|
<button type="button" class="btn btn-link form-icon" title="Edit title from website">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
|
||||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path fill-rule="evenodd"
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||||
clip-rule="evenodd"/>
|
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||||
|
<path d="M16 5l3 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Optional, leave empty to use title from website.
|
Optional, leave empty to use title from website.
|
||||||
@@ -53,14 +54,15 @@
|
|||||||
<div class="has-icon-right">
|
<div class="has-icon-right">
|
||||||
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
|
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
|
||||||
<i class="form-icon loading"></i>
|
<i class="form-icon loading"></i>
|
||||||
<a class="btn btn-link form-icon" title="Edit description from website">
|
<button type="button" class="btn btn-link form-icon" title="Edit description from website">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
|
||||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path fill-rule="evenodd"
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||||
clip-rule="evenodd"/>
|
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||||
|
<path d="M16 5l3 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Optional, leave empty to use description from website.
|
Optional, leave empty to use description from website.
|
||||||
@@ -74,11 +76,11 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||||
<div class="form-input-hint">
|
|
||||||
Additional notes, supports Markdown.
|
|
||||||
</div>
|
|
||||||
{{ form.notes.errors }}
|
|
||||||
</details>
|
</details>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Additional notes, supports Markdown.
|
||||||
|
</div>
|
||||||
|
{{ form.notes.errors }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||||
@@ -106,12 +108,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br/>
|
<div class="divider"></div>
|
||||||
<div class="form-group">
|
<div class="form-group d-flex justify-between">
|
||||||
{% if auto_close %}
|
{% if auto_close %}
|
||||||
<input type="submit" value="Save and close" class="btn btn-primary mr-2">
|
<input type="submit" value="Save and close" class="btn btn-primary btn-wide">
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="submit" value="Save" class="btn btn-primary mr-2">
|
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,6 +199,18 @@
|
|||||||
} else {
|
} else {
|
||||||
bookmarkExistsHint.style['display'] = 'none';
|
bookmarkExistsHint.style['display'] = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preview auto tags
|
||||||
|
const autoTags = data.auto_tags;
|
||||||
|
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
|
||||||
|
|
||||||
|
if (autoTags.length > 0) {
|
||||||
|
autoTags.sort();
|
||||||
|
autoTagsHint.style['display'] = 'block';
|
||||||
|
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
|
||||||
|
} else {
|
||||||
|
autoTagsHint.style['display'] = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load sass_tags %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
{# Use data attributes as storage for access in static scripts #}
|
{# Use data attributes as storage for access in static scripts #}
|
||||||
@@ -17,19 +16,18 @@
|
|||||||
<meta name="robots" content="index,follow">
|
<meta name="robots" content="index,follow">
|
||||||
<meta name="author" content="Sascha Ißbrücker">
|
<meta name="author" content="Sascha Ißbrücker">
|
||||||
<title>linkding</title>
|
<title>linkding</title>
|
||||||
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
|
||||||
{# Include specific theme variant based on user profile setting #}
|
{# Include specific theme variant based on user profile setting #}
|
||||||
{% if request.user_profile.theme == 'light' %}
|
{% if request.user_profile.theme == 'light' %}
|
||||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
<meta name="theme-color" content="#5856e0">
|
<meta name="theme-color" content="#5856e0">
|
||||||
{% elif request.user_profile.theme == 'dark' %}
|
{% elif request.user_profile.theme == 'dark' %}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
<meta name="theme-color" content="#161822">
|
<meta name="theme-color" content="#161822">
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Use auto theme as fallback #}
|
{# Use auto theme as fallback #}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: dark)"/>
|
media="(prefers-color-scheme: dark)"/>
|
||||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: light)"/>
|
media="(prefers-color-scheme: light)"/>
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||||
@@ -37,6 +35,11 @@
|
|||||||
{% if request.user_profile.custom_css %}
|
{% if request.user_profile.custom_css %}
|
||||||
<style>{{ request.user_profile.custom_css }}</style>
|
<style>{{ request.user_profile.custom_css }}</style>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<meta name="turbo-cache-control" content="no-preview">
|
||||||
|
{% if not request.global_settings.enable_link_prefetch %}
|
||||||
|
<meta name="turbo-prefetch" content="false">
|
||||||
|
{% endif %}
|
||||||
|
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body ld-global-shortcuts>
|
<body ld-global-shortcuts>
|
||||||
|
|
||||||
@@ -114,16 +117,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="d-flex justify-between">
|
<div class="d-flex justify-between">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
|
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
|
||||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||||
<h1>LINKDING</h1>
|
<h1>LINKDING</h1>
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
{# Only show nav items menu when logged in #}
|
{# Only show nav items menu when logged in #}
|
||||||
{% include 'bookmarks/nav_menu.html' %}
|
{% include 'bookmarks/nav_menu.html' %}
|
||||||
{% elif has_public_shares %}
|
{% else %}
|
||||||
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
|
{# Otherwise show login link #}
|
||||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -131,6 +134,5 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -1,88 +1,83 @@
|
|||||||
{% load shared %}
|
{% load shared %}
|
||||||
{% htmlmin %}
|
{% htmlmin %}
|
||||||
{# Basic menu list #}
|
{# Basic menu list #}
|
||||||
<div class="hide-md">
|
<div class="hide-md">
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
|
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||||
Bookmarks
|
Bookmarks
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
</button>
|
||||||
style="height:1rem;width:1rem;vertical-align: middle;">
|
<ul class="menu">
|
||||||
<path fill-rule="evenodd"
|
<li class="menu-item">
|
||||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||||
clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<ul class="menu">
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
|
||||||
</li>
|
|
||||||
{% if request.user_profile.enable_sharing %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
<li class="menu-item">
|
||||||
<li>
|
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
|
</li>
|
||||||
</li>
|
{% if request.user_profile.enable_sharing %}
|
||||||
<li>
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
|
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
{% endif %}
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||||
|
</li>
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||||
|
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-link">Logout</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
{# Menu drop-down for smaller devices #}
|
||||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
<div class="show-md">
|
||||||
{% csrf_token %}
|
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||||
<button type="submit" class="btn btn-link">Logout</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{# Menu drop-down for smaller devices #}
|
|
||||||
<div class="show-md">
|
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
|
||||||
style="width: 24px; height: 24px">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<div ld-dropdown class="dropdown dropdown-right">
|
|
||||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
style="width: 24px; height: 24px">
|
style="width: 24px; height: 24px">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<!-- menu component -->
|
<div ld-dropdown class="dropdown dropdown-right">
|
||||||
<ul class="menu">
|
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||||
<li>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
|
style="width: 24px; height: 24px">
|
||||||
</li>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
<li style="padding-left: 1rem">
|
</svg>
|
||||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
</button>
|
||||||
</li>
|
<!-- menu component -->
|
||||||
{% if request.user_profile.enable_sharing %}
|
<ul class="menu">
|
||||||
<li style="padding-left: 1rem">
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
<li class="menu-item">
|
||||||
<li style="padding-left: 1rem">
|
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
|
</li>
|
||||||
</li>
|
{% if request.user_profile.enable_sharing %}
|
||||||
<li style="padding-left: 1rem">
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
|
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{% endif %}
|
||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
<li class="menu-item">
|
||||||
</li>
|
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||||
<li>
|
</li>
|
||||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
<li class="menu-item">
|
||||||
{% csrf_token %}
|
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||||
<button type="submit" class="btn btn-link">Logout</button>
|
</li>
|
||||||
</form>
|
<div class="divider"></div>
|
||||||
</li>
|
<li class="menu-item">
|
||||||
</ul>
|
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
||||||
|
</li>
|
||||||
|
<li class="menu-item">
|
||||||
|
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-link menu-link">Logout</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endhtmlmin %}
|
{% endhtmlmin %}
|
||||||
|
@@ -2,12 +2,14 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="content-area">
|
<div class="bookmarks-form-page">
|
||||||
<div class="content-area-header">
|
<section class="content-area">
|
||||||
<h2>New bookmark</h2>
|
<div class="content-area-header">
|
||||||
</div>
|
<h2>New bookmark</h2>
|
||||||
<form action="{% url 'bookmarks:new' %}" method="post" class="width-50 width-md-100" novalidate>
|
</div>
|
||||||
{% bookmark_form form return_url auto_close=auto_close %}
|
<form action="{% url 'bookmarks:new' %}" method="post" novalidate>
|
||||||
</form>
|
{% bookmark_form form return_url auto_close=auto_close %}
|
||||||
</section>
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
{% load sass_tags %}
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="reader-mode">
|
<html lang="en" class="reader-mode">
|
||||||
@@ -6,16 +5,21 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Reader view</title>
|
<title>Reader view</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||||
|
{# Include specific theme variant based on user profile setting #}
|
||||||
{% if request.user_profile.theme == 'light' %}
|
{% if request.user_profile.theme == 'light' %}
|
||||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
|
<meta name="theme-color" content="#5856e0">
|
||||||
{% elif request.user_profile.theme == 'dark' %}
|
{% elif request.user_profile.theme == 'dark' %}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
|
<meta name="theme-color" content="#161822">
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Use auto theme as fallback #}
|
{# Use auto theme as fallback #}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: dark)"/>
|
media="(prefers-color-scheme: dark)"/>
|
||||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: light)"/>
|
media="(prefers-color-scheme: light)"/>
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
{% load widget_tweaks %}
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<form id="search" class="input-group" action="" method="get" role="search">
|
<form id="search" action="" method="get" role="search">
|
||||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||||
value="{{ search.q }}">
|
value="{{ search.q }}">
|
||||||
<input type="submit" value="Search" class="btn input-group-btn">
|
<input type="submit" value="Search" class="d-none">
|
||||||
{% for hidden_field in search_form.hidden_fields %}
|
{% for hidden_field in search_form.hidden_fields %}
|
||||||
{{ hidden_field }}
|
{{ hidden_field }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
{# Replace search input with auto-complete component #}
|
{# Replace search input with auto-complete component #}
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
window.addEventListener("load", function () {
|
(function init() {
|
||||||
const currentTagsString = '{{ tags_string }}';
|
const currentTagsString = '{{ tags_string }}';
|
||||||
const currentTags = currentTagsString.split(' ');
|
const currentTags = currentTagsString.split(' ');
|
||||||
const uniqueTags = [...new Set(currentTags)]
|
const uniqueTags = [...new Set(currentTags)]
|
||||||
@@ -104,5 +104,5 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
input.replaceWith(wrapper.firstElementChild);
|
input.replaceWith(wrapper.firstElementChild);
|
||||||
});
|
})();
|
||||||
</script>
|
</script>
|
@@ -7,18 +7,19 @@
|
|||||||
{% include 'settings/nav.html' %}
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
{# Profile section #}
|
{# Profile section #}
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
{% if success_message %}
|
|
||||||
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
<p>
|
<p>
|
||||||
<a href="{% url 'change_password' %}">Change password</a>
|
<a href="{% url 'change_password' %}">Change password</a>
|
||||||
</p>
|
</p>
|
||||||
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
|
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Bookmark actions</label>
|
<label class="form-label">Bookmark actions</label>
|
||||||
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
|
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
|
||||||
{{ form.display_view_bookmark_action }}
|
{{ form.display_view_bookmark_action }}
|
||||||
<i class="form-icon"></i> View
|
<i class="form-icon"></i> View
|
||||||
@@ -110,6 +111,31 @@
|
|||||||
result will also include bookmarks where a search term matches otherwise.
|
result will also include bookmarks where a search term matches otherwise.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
|
||||||
|
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
In alphabetical mode, tags will be grouped by the first letter.
|
||||||
|
If disabled, tags will not be grouped.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<details {% if form.auto_tagging_rules.value %}open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
<span class="form-label d-inline-block">Auto Tagging</span>
|
||||||
|
</summary>
|
||||||
|
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
|
||||||
|
<div>
|
||||||
|
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Automatically adds tags to bookmarks based on predefined rules.
|
||||||
|
Each line is a single rule that maps a URL to one or more tags. For example:
|
||||||
|
<pre>youtube.com video
|
||||||
|
reddit.com/r/Music music reddit</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||||
{{ form.enable_favicons }}
|
{{ form.enable_favicons }}
|
||||||
@@ -117,6 +143,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||||
|
Enabling this feature automatically downloads all missing favicons.
|
||||||
By default, this feature uses a <b>Google service</b> to download favicons.
|
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||||
If you don't want to use this service, check the <a
|
If you don't want to use this service, check the <a
|
||||||
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
|
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
|
||||||
@@ -127,6 +154,16 @@
|
|||||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.enable_preview_images.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.enable_preview_images }}
|
||||||
|
<i class="form-icon"></i> Enable Preview Images
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Automatically loads preview images for bookmarked websites and displays them next to each bookmark.
|
||||||
|
Enabling this feature automatically downloads all missing preview images.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||||
integration</label>
|
integration</label>
|
||||||
@@ -188,10 +225,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<details {% if form.custom_css.value %}open{% endif %}>
|
<details {% if form.custom_css.value %}open{% endif %}>
|
||||||
<summary>Custom CSS</summary>
|
<summary>
|
||||||
|
<span class="form-label d-inline-block">Custom CSS</span>
|
||||||
|
</summary>
|
||||||
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
||||||
<div class="mt-2">
|
<div>
|
||||||
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
|
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
@@ -199,11 +238,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
<input type="submit" name="update_profile" value="Save" class="btn btn-primary btn-wide mt-2">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# Global settings section #}
|
||||||
|
{% if global_settings_form %}
|
||||||
|
<section class="content-area">
|
||||||
|
<h2>Global settings</h2>
|
||||||
|
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
|
||||||
|
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
The page that unauthenticated users are redirected to when accessing the root URL.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
|
||||||
|
profile</label>
|
||||||
|
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
|
||||||
|
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
|
||||||
|
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ global_settings_form.enable_link_prefetch.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ global_settings_form.enable_link_prefetch }}
|
||||||
|
<i class="form-icon"></i> Enable prefetching links on hover
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Prefetches internal links when hovering over them. This can improve the perceived performance when
|
||||||
|
navigating application, but also increases the load on the server as well as bandwidth usage.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary btn-wide mt-2">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Import section #}
|
{# Import section #}
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Import</h2>
|
<h2>Import</h2>
|
||||||
@@ -236,7 +316,7 @@
|
|||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Export</h2>
|
<h2>Export</h2>
|
||||||
<p>Export all bookmarks in Netscape HTML format.</p>
|
<p>Export all bookmarks in Netscape HTML format.</p>
|
||||||
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
<a class="btn btn-primary" target="_blank" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||||
{% if export_error %}
|
{% if export_error %}
|
||||||
<div class="has-error">
|
<div class="has-error">
|
||||||
<p class="form-input-hint">
|
<p class="form-input-hint">
|
||||||
@@ -274,35 +354,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
(function init() {
|
||||||
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||||
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||||
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
||||||
|
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
||||||
|
|
||||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||||
function updatePublicSharing() {
|
function updatePublicSharing() {
|
||||||
if (enableSharing.checked) {
|
if (enableSharing.checked) {
|
||||||
enablePublicSharing.disabled = false;
|
enablePublicSharing.disabled = false;
|
||||||
} else {
|
} else {
|
||||||
enablePublicSharing.disabled = true;
|
enablePublicSharing.disabled = true;
|
||||||
enablePublicSharing.checked = false;
|
enablePublicSharing.checked = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updatePublicSharing();
|
updatePublicSharing();
|
||||||
enableSharing.addEventListener("change", updatePublicSharing);
|
enableSharing.addEventListener("change", updatePublicSharing);
|
||||||
|
|
||||||
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||||
function updateBookmarkDescriptionMaxLines() {
|
function updateBookmarkDescriptionMaxLines() {
|
||||||
if (bookmarkDescriptionDisplay.value === "inline") {
|
if (bookmarkDescriptionDisplay.value === "inline") {
|
||||||
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
|
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
|
||||||
} else {
|
} else {
|
||||||
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
|
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updateBookmarkDescriptionMaxLines();
|
updateBookmarkDescriptionMaxLines();
|
||||||
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
|
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,70 +1,88 @@
|
|||||||
{% extends "bookmarks/layout.html" %}
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
|
|
||||||
{% include 'settings/nav.html' %}
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Browser Extension</h2>
|
<h2>Browser Extension</h2>
|
||||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
|
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
|
||||||
<ul>
|
extension is available in the official extension stores for:</p>
|
||||||
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
<ul>
|
||||||
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
|
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||||
</ul>
|
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
|
||||||
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
target="_blank">Chrome</a></li>
|
||||||
<h2>Bookmarklet</h2>
|
</ul>
|
||||||
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
|
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
|
||||||
first. Here's how it works:</p>
|
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
||||||
<ul>
|
<h2>Bookmarklet</h2>
|
||||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
|
||||||
<li>Open the website that you want to bookmark</li>
|
application first. Here's how it works:</p>
|
||||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
<ul>
|
||||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
<li>Open the website that you want to bookmark</li>
|
||||||
</ul>
|
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||||
class="btn btn-primary">📎 Add bookmark</a>
|
</ul>
|
||||||
</section>
|
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
||||||
|
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||||
|
class="btn btn-primary">📎 Add bookmark</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>REST API</h2>
|
<h2>REST API</h2>
|
||||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="columns">
|
<div class="width-50 width-md-100">
|
||||||
<div class="column width-50 width-md-100">
|
<input class="form-input" value="{{ api_token }}" readonly>
|
||||||
<input class="form-input" value="{{ api_token }}" readonly>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p>
|
||||||
</div>
|
<strong>Please treat this token as you would any other credential.</strong>
|
||||||
<p>
|
Any party with access to this token can access and manage all your bookmarks.
|
||||||
<strong>Please treat this token as you would any other credential.</strong>
|
If you think that a token was compromised you can revoke (delete) it in the <a
|
||||||
Any party with access to this token can access and manage all your bookmarks.
|
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
||||||
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
After deleting the token, a new one will be generated when you reload this settings page.
|
||||||
After deleting the token, a new one will be generated when you reload this settings page.
|
</p>
|
||||||
</p>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>RSS Feeds</h2>
|
<h2>RSS Feeds</h2>
|
||||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||||
<ul style="list-style-position: outside;">
|
<ul style="list-style-position: outside;">
|
||||||
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
|
<li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||||
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
<li><a target="_blank" href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
||||||
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
|
<li><a target="_blank" href="{{ shared_feed_url }}">Shared bookmarks</a></li>
|
||||||
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
|
<li><a target="_blank" href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-secondary">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
|
||||||
</ul>
|
</li>
|
||||||
<p>
|
</ul>
|
||||||
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
<p>
|
||||||
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
|
All URLs support the following URL parameters:
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<ul style="list-style-position: outside;">
|
||||||
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
|
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
|
||||||
Any party with access to these URLs can read all your bookmarks.
|
default, only the latest 100 matching bookmarks are included.
|
||||||
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
|
</li>
|
||||||
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
<li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
|
||||||
</p>
|
the bookmarks view and then copying the parameter from the URL.
|
||||||
</section>
|
</li>
|
||||||
</div>
|
<li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
|
||||||
|
bookmarks and <code>no</code> for read bookmarks.
|
||||||
|
</li>
|
||||||
|
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
|
||||||
|
shared bookmarks and <code>no</code> for unshared bookmarks.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>Please note that these URLs include an authentication token that should be treated like any other
|
||||||
|
credential.</strong>
|
||||||
|
Any party with access to these URLs can read all your bookmarks.
|
||||||
|
If you think that a URL was compromised you can delete the feed token for your user in the <a
|
||||||
|
target="_blank" href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
|
||||||
|
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
||||||
</li>
|
</li>
|
||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features 
 such as user management and bulk operations.">
|
<li class="tab-item">
|
||||||
<a href="{% url 'admin:index' %}" target="_blank">
|
<a href="{% url 'admin:index' %}" target="_blank">
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
|
||||||
|
@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
|
|||||||
|
|
||||||
return self.user
|
return self.user
|
||||||
|
|
||||||
|
def setup_superuser(self):
|
||||||
|
return User.objects.create_superuser(
|
||||||
|
"superuser", "superuser@example.com", "password123"
|
||||||
|
)
|
||||||
|
|
||||||
def setup_bookmark(
|
def setup_bookmark(
|
||||||
self,
|
self,
|
||||||
is_archived: bool = False,
|
is_archived: bool = False,
|
||||||
@@ -39,6 +44,7 @@ class BookmarkFactoryMixin:
|
|||||||
website_description: str = "",
|
website_description: str = "",
|
||||||
web_archive_snapshot_url: str = "",
|
web_archive_snapshot_url: str = "",
|
||||||
favicon_file: str = "",
|
favicon_file: str = "",
|
||||||
|
preview_image_file: str = "",
|
||||||
added: datetime = None,
|
added: datetime = None,
|
||||||
):
|
):
|
||||||
if title is None:
|
if title is None:
|
||||||
@@ -67,6 +73,7 @@ class BookmarkFactoryMixin:
|
|||||||
shared=shared,
|
shared=shared,
|
||||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||||
favicon_file=favicon_file,
|
favicon_file=favicon_file,
|
||||||
|
preview_image_file=preview_image_file,
|
||||||
)
|
)
|
||||||
bookmark.save()
|
bookmark.save()
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
@@ -85,6 +92,8 @@ class BookmarkFactoryMixin:
|
|||||||
shared: bool = False,
|
shared: bool = False,
|
||||||
with_tags: bool = False,
|
with_tags: bool = False,
|
||||||
with_web_archive_snapshot_url: bool = False,
|
with_web_archive_snapshot_url: bool = False,
|
||||||
|
with_favicon_file: bool = False,
|
||||||
|
with_preview_image_file: bool = False,
|
||||||
user: User = None,
|
user: User = None,
|
||||||
):
|
):
|
||||||
user = user or self.get_or_create_test_user()
|
user = user or self.get_or_create_test_user()
|
||||||
@@ -116,6 +125,12 @@ class BookmarkFactoryMixin:
|
|||||||
web_archive_snapshot_url = ""
|
web_archive_snapshot_url = ""
|
||||||
if with_web_archive_snapshot_url:
|
if with_web_archive_snapshot_url:
|
||||||
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
||||||
|
favicon_file = ""
|
||||||
|
if with_favicon_file:
|
||||||
|
favicon_file = f"favicon_{i}.png"
|
||||||
|
preview_image_file = ""
|
||||||
|
if with_preview_image_file:
|
||||||
|
preview_image_file = f"preview_image_{i}.png"
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
url=url,
|
url=url,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -124,6 +139,8 @@ class BookmarkFactoryMixin:
|
|||||||
shared=shared,
|
shared=shared,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||||
|
favicon_file=favicon_file,
|
||||||
|
preview_image_file=preview_image_file,
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
bookmarks.append(bookmark)
|
bookmarks.append(bookmark)
|
||||||
|
@@ -1,29 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|
||||||
|
|
||||||
|
|
||||||
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
|
|
||||||
def assertSharedBookmarksLinkCount(self, response, count):
|
|
||||||
url = reverse("bookmarks:shared")
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
|
|
||||||
count=count,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_publicly_shared_bookmarks_link(self):
|
|
||||||
# should not render link if no public shares exist
|
|
||||||
user = self.setup_user(enable_sharing=True)
|
|
||||||
self.setup_bookmark(user=user, shared=True)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("login"))
|
|
||||||
self.assertSharedBookmarksLinkCount(response, 0)
|
|
||||||
|
|
||||||
# should render link if public shares exist
|
|
||||||
user.profile.enable_public_sharing = True
|
|
||||||
user.profile.save()
|
|
||||||
|
|
||||||
response = self.client.get(reverse("login"))
|
|
||||||
self.assertSharedBookmarksLinkCount(response, 1)
|
|
190
bookmarks/tests/test_auto_tagging.py
Normal file
190
bookmarks/tests/test_auto_tagging.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
from bookmarks.services import auto_tagging
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class AutoTaggingTestCase(TestCase):
|
||||||
|
def test_auto_tag_by_domain(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
test.com test
|
||||||
|
"""
|
||||||
|
url = "https://example.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_works_with_port(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
test.com test
|
||||||
|
"""
|
||||||
|
url = "https://example.com:8080/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_ignores_case(self):
|
||||||
|
script = """
|
||||||
|
EXAMPLE.com example
|
||||||
|
"""
|
||||||
|
url = "https://example.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_should_add_all_tags(self):
|
||||||
|
script = """
|
||||||
|
example.com one two three
|
||||||
|
"""
|
||||||
|
url = "https://example.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"one", "two", "three"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_work_with_idn_domains(self):
|
||||||
|
script = """
|
||||||
|
रजिस्ट्री.भारत tag1
|
||||||
|
"""
|
||||||
|
url = "https://www.xn--81bg3cc2b2bk5hb.xn--h2brj9c/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"tag1"})
|
||||||
|
|
||||||
|
script = """
|
||||||
|
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
|
||||||
|
"""
|
||||||
|
url = "https://www.रजिस्ट्री.भारत/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"tag1"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_and_path(self):
|
||||||
|
script = """
|
||||||
|
example.com/one one
|
||||||
|
example.com/two two
|
||||||
|
test.com test
|
||||||
|
"""
|
||||||
|
url = "https://example.com/one/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"one"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_and_path_ignores_case(self):
|
||||||
|
script = """
|
||||||
|
example.com/One one
|
||||||
|
"""
|
||||||
|
url = "https://example.com/one/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"one"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
|
||||||
|
script = """
|
||||||
|
example.com/one one
|
||||||
|
example.com/two two
|
||||||
|
test.com test
|
||||||
|
"""
|
||||||
|
url = "https://example.com/one/two"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"one"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_ignores_domain_in_path(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
"""
|
||||||
|
url = "https://test.com/example.com"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, set([]))
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_includes_subdomains(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
test.example.com test
|
||||||
|
some.example.com some
|
||||||
|
"""
|
||||||
|
url = "https://test.example.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"example", "test"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_matches_domain_rtl(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
"""
|
||||||
|
url = "https://example.com.bad-website.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, set([]))
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_ignores_schema(self):
|
||||||
|
script = """
|
||||||
|
https://example.com/ https
|
||||||
|
http://example.com/ http
|
||||||
|
"""
|
||||||
|
url = "http://example.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"https", "http"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
|
||||||
|
script = """
|
||||||
|
example.com
|
||||||
|
"""
|
||||||
|
url = "https://example.com/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, set([]))
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_path_and_qs(self):
|
||||||
|
script = """
|
||||||
|
example.com/page?a=b tag1 # true, matches a=b
|
||||||
|
example.com/page?a=c&c=d tag2 # true, matches both a=c and c=d
|
||||||
|
example.com/page?c=d&l=p tag3 # false, l=p doesn't exists
|
||||||
|
example.com/page?a=bb tag4 # false bb != b
|
||||||
|
example.com/page?a=b&a=c tag5 # true, matches both a=b and a=c
|
||||||
|
example.com/page?a=B tag6 # true, matches a=b because case insensitive
|
||||||
|
example.com/page?A=b tag7 # true, matches a=b because case insensitive
|
||||||
|
"""
|
||||||
|
url = "https://example.com/page/some?z=x&a=b&v=b&c=d&o=p&a=c"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"tag1", "tag2", "tag5", "tag6", "tag7"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
|
||||||
|
script = """
|
||||||
|
example.com/page?a= tag1
|
||||||
|
example.com/page?b= tag2
|
||||||
|
"""
|
||||||
|
url = "https://example.com/page/some?a=value"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"tag1"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
|
||||||
|
script = """
|
||||||
|
example.com/page?a=йцу tag1
|
||||||
|
example.com/page?a=%D0%B9%D1%86%D1%83 tag2
|
||||||
|
"""
|
||||||
|
url = "https://example.com/page?a=%D0%B9%D1%86%D1%83"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"tag1", "tag2"})
|
@@ -1,10 +1,10 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.db import connections
|
||||||
|
from django.db.utils import DEFAULT_DB_ALIAS
|
||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db import connections
|
|
||||||
from django.db.utils import DEFAULT_DB_ALIAS
|
|
||||||
|
|
||||||
|
from bookmarks.models import GlobalSettings
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +20,12 @@ class BookmarkArchivedViewPerformanceTestCase(
|
|||||||
return connections[DEFAULT_DB_ALIAS]
|
return connections[DEFAULT_DB_ALIAS]
|
||||||
|
|
||||||
def test_should_not_increase_number_of_queries_per_bookmark(self):
|
def test_should_not_increase_number_of_queries_per_bookmark(self):
|
||||||
|
# create global settings
|
||||||
|
GlobalSettings.get()
|
||||||
|
|
||||||
# create initial bookmarks
|
# create initial bookmarks
|
||||||
num_initial_bookmarks = 10
|
num_initial_bookmarks = 10
|
||||||
for index in range(num_initial_bookmarks):
|
for _ in range(num_initial_bookmarks):
|
||||||
self.setup_bookmark(user=self.user, is_archived=True)
|
self.setup_bookmark(user=self.user, is_archived=True)
|
||||||
|
|
||||||
# capture number of queries
|
# capture number of queries
|
||||||
@@ -37,7 +40,7 @@ class BookmarkArchivedViewPerformanceTestCase(
|
|||||||
|
|
||||||
# add more bookmarks
|
# add more bookmarks
|
||||||
num_additional_bookmarks = 10
|
num_additional_bookmarks = 10
|
||||||
for index in range(num_additional_bookmarks):
|
for _ in range(num_additional_bookmarks):
|
||||||
self.setup_bookmark(user=self.user, is_archived=True)
|
self.setup_bookmark(user=self.user, is_archived=True)
|
||||||
|
|
||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import formats
|
from django.utils import formats, timezone
|
||||||
|
|
||||||
from bookmarks.models import BookmarkAsset, UserProfile
|
from bookmarks.models import BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import bookmarks, tasks
|
from bookmarks.services import bookmarks, tasks
|
||||||
@@ -180,7 +181,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# no latest snapshot
|
# no latest snapshot
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 1)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# snapshot is not complete
|
# snapshot is not complete
|
||||||
self.setup_asset(
|
self.setup_asset(
|
||||||
@@ -194,7 +195,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
status=BookmarkAsset.STATUS_FAILURE,
|
status=BookmarkAsset.STATUS_FAILURE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 1)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# not a snapshot
|
# not a snapshot
|
||||||
self.setup_asset(
|
self.setup_asset(
|
||||||
@@ -203,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
status=BookmarkAsset.STATUS_COMPLETE,
|
status=BookmarkAsset.STATUS_COMPLETE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 1)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# snapshot is complete
|
# snapshot is complete
|
||||||
asset = self.setup_asset(
|
asset = self.setup_asset(
|
||||||
@@ -212,20 +213,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
status=BookmarkAsset.STATUS_COMPLETE,
|
status=BookmarkAsset.STATUS_COMPLETE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 2)
|
self.assertEqual(self.count_weblinks(soup), 3)
|
||||||
|
|
||||||
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
||||||
link = self.find_weblink(soup, reader_mode_url)
|
link = self.find_weblink(soup, reader_mode_url)
|
||||||
self.assertIsNotNone(link)
|
self.assertIsNotNone(link)
|
||||||
|
|
||||||
def test_internet_archive_link(self):
|
def test_internet_archive_link_with_snapshot_url(self):
|
||||||
# without snapshot url
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
soup = self.get_details(bookmark)
|
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
|
||||||
self.assertIsNone(link)
|
|
||||||
|
|
||||||
# with snapshot url
|
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
@@ -264,6 +258,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
image = link.select_one("svg")
|
image = link.select_one("svg")
|
||||||
self.assertIsNotNone(image)
|
self.assertIsNotNone(image)
|
||||||
|
|
||||||
|
def test_internet_archive_link_with_fallback_url(self):
|
||||||
|
date_added = timezone.datetime(
|
||||||
|
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(url="https://example.com/", added=date_added)
|
||||||
|
fallback_web_archive_url = (
|
||||||
|
"https://web.archive.org/web/20230811214511/https://example.com/"
|
||||||
|
)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, fallback_web_archive_url)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link["href"], fallback_web_archive_url)
|
||||||
|
self.assertEqual(link.text.strip(), "Internet Archive")
|
||||||
|
|
||||||
def test_weblinks_respect_target_setting(self):
|
def test_weblinks_respect_target_setting(self):
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
|
|
||||||
@@ -300,6 +309,36 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
|
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_preview_image(self):
|
||||||
|
# without image
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
image = soup.select_one("div.preview-image img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# with image
|
||||||
|
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
image = soup.select_one("div.preview-image img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# preview images enabled, no image
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_preview_images = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
image = soup.select_one("div.preview-image img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# preview images enabled, image present
|
||||||
|
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
image = soup.select_one("div.preview-image img")
|
||||||
|
self.assertIsNotNone(image)
|
||||||
|
self.assertEqual(image["src"], "/static/example.png")
|
||||||
|
|
||||||
def test_status(self):
|
def test_status(self):
|
||||||
# renders form
|
# renders form
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user