mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-14 21:19:59 +02:00
Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2b3cd2dec1 | ||
![]() |
c22e30cbda | ||
![]() |
ffaaf0521d | ||
![]() |
db225d5267 | ||
![]() |
74e65bc366 | ||
![]() |
edba98f1fe | ||
![]() |
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
|
||||||
|
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,5 +1,101 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.33.0 (14/09/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Theme improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/822
|
||||||
|
* Speed up navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/824
|
||||||
|
* Rename "SingeFileError" to "SingleFileError" by @curiousleo in https://github.com/sissbruecker/linkding/pull/823
|
||||||
|
* Bump svelte from 4.2.12 to 4.2.19 by @dependabot in https://github.com/sissbruecker/linkding/pull/806
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @curiousleo made their first contribution in https://github.com/sissbruecker/linkding/pull/823
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.32.0...v1.33.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.32.0 (10/09/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Allow configuring landing page for unauthenticated users by @sissbruecker in https://github.com/sissbruecker/linkding/pull/808
|
||||||
|
* Allow configuring guest user profile by @sissbruecker in https://github.com/sissbruecker/linkding/pull/809
|
||||||
|
* Return bookmark tags in RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/810
|
||||||
|
* Additional filter parameters for RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/811
|
||||||
|
* Allow pre-filling notes in new bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/812
|
||||||
|
* Fix inconsistent tag order in bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/819
|
||||||
|
* Fix auto-tagging when URL includes port by @sissbruecker in https://github.com/sissbruecker/linkding/pull/820
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.1...v1.32.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.31.1 (30/08/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
|
||||||
|
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
|
||||||
|
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
|
||||||
|
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
|
||||||
|
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
|
||||||
|
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
|
||||||
|
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
|
||||||
|
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
|
||||||
|
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
|
||||||
|
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
|
||||||
|
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
|
||||||
|
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
|
||||||
|
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
|
||||||
|
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
|
||||||
|
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
|
||||||
|
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.31.0 (16/06/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
|
||||||
|
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
|
||||||
|
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
|
||||||
|
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
|
||||||
|
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
|
||||||
|
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
|
||||||
|
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
|
||||||
|
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
|
||||||
|
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.30.0 (20/04/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* 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}
|
||||||
|
@@ -121,8 +121,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with self.page.expect_navigation():
|
with self.page.expect_navigation():
|
||||||
details_modal.get_by_text("Edit").click()
|
details_modal.get_by_text("Edit").click()
|
||||||
|
|
||||||
# Cancel edit, verify return url
|
# Cancel edit, verify return to details url
|
||||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
details_url = url + f"&details={bookmark.id}"
|
||||||
|
with self.page.expect_navigation(url=self.live_server_url + details_url):
|
||||||
self.page.get_by_text("Nevermind").click()
|
self.page.get_by_text("Nevermind").click()
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
@@ -167,7 +168,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
# Has new snapshots
|
# Has new snapshots
|
||||||
expect(snapshot).to_be_visible()
|
expect(snapshot).to_be_visible()
|
||||||
|
|
||||||
# Create snapshot
|
# Remove snapshot
|
||||||
asset_list.get_by_text("Remove", exact=False).click()
|
asset_list.get_by_text("Remove", exact=False).click()
|
||||||
asset_list.get_by_text("Confirm", exact=False).click()
|
asset_list.get_by_text("Confirm", exact=False).click()
|
||||||
|
|
||||||
|
@@ -1,37 +0,0 @@
|
|||||||
from django.urls import reverse
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
|
||||||
def test_edit_return_url(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
|
||||||
|
|
||||||
# Navigate to edit page
|
|
||||||
with self.page.expect_navigation():
|
|
||||||
self.page.get_by_text("Edit").click()
|
|
||||||
|
|
||||||
# Cancel edit, verify return url
|
|
||||||
with self.page.expect_navigation(
|
|
||||||
url=self.live_server_url
|
|
||||||
+ reverse("bookmarks:details", args=[bookmark.id])
|
|
||||||
):
|
|
||||||
self.page.get_by_text("Nevermind").click()
|
|
||||||
|
|
||||||
def test_delete_return_url(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
|
||||||
|
|
||||||
# Trigger delete, verify return url
|
|
||||||
# Should probably return to last bookmark list page, but for now just returns to index
|
|
||||||
with self.page.expect_navigation(
|
|
||||||
url=self.live_server_url + reverse("bookmarks:index")
|
|
||||||
):
|
|
||||||
self.page.get_by_text("Delete...").click()
|
|
||||||
self.page.get_by_text("Confirm").click()
|
|
@@ -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()
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
from django.test import override_settings
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from playwright.sync_api import sync_playwright, expect, Locator
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
from bookmarks.models import Bookmark
|
|
||||||
|
|
||||||
|
|
||||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
@@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
# verify modal is visible
|
# verify modal is visible
|
||||||
modal = page.locator(".modal")
|
modal = page.locator(".modal")
|
||||||
expect(modal).to_be_visible()
|
expect(modal).to_be_visible()
|
||||||
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
expect(modal.locator("h2")).to_have_text("Tags")
|
||||||
|
|
||||||
# close with close button
|
# close with close button
|
||||||
modal.locator("button.close").click()
|
modal.locator("button.close").click()
|
||||||
|
@@ -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
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
export class ApiClient {
|
export class Api {
|
||||||
constructor(baseUrl) {
|
constructor(baseUrl) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
@@ -27,3 +27,6 @@ export class ApiClient {
|
|||||||
.then((data) => data.results);
|
.then((data) => data.results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||||
|
export const api = new Api(apiBaseUrl);
|
||||||
|
@@ -5,9 +5,10 @@ class BookmarkItem extends Behavior {
|
|||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
// Toggle notes
|
// Toggle notes
|
||||||
const notesToggle = element.querySelector(".toggle-notes");
|
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||||
if (notesToggle) {
|
this.notesToggle = element.querySelector(".toggle-notes");
|
||||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
if (this.notesToggle) {
|
||||||
|
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tooltip to title if it is truncated
|
// Add tooltip to title if it is truncated
|
||||||
@@ -20,6 +21,12 @@ class BookmarkItem extends Behavior {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.notesToggle) {
|
||||||
|
this.notesToggle.removeEventListener("click", this.onToggleNotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onToggleNotes(event) {
|
onToggleNotes(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@@ -4,16 +4,22 @@ class BulkEdit extends Behavior {
|
|||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
this.active = false;
|
this.active = element.classList.contains("active");
|
||||||
|
|
||||||
|
this.init = this.init.bind(this);
|
||||||
this.onToggleActive = this.onToggleActive.bind(this);
|
this.onToggleActive = this.onToggleActive.bind(this);
|
||||||
this.onToggleAll = this.onToggleAll.bind(this);
|
this.onToggleAll = this.onToggleAll.bind(this);
|
||||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||||
this.onActionSelected = this.onActionSelected.bind(this);
|
this.onActionSelected = this.onActionSelected.bind(this);
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
// Reset when bookmarks are refreshed
|
// Reset when bookmarks are updated
|
||||||
document.addEventListener("refresh-bookmark-list-done", () => this.init());
|
document.addEventListener("bookmark-list-updated", this.init);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.removeListeners();
|
||||||
|
document.removeEventListener("bookmark-list-updated", this.init);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -31,13 +37,9 @@ class BulkEdit extends Behavior {
|
|||||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove previous listeners if elements are the same
|
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
||||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
this.removeListeners();
|
||||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
this.addListeners();
|
||||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
|
||||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
|
||||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset checkbox states
|
// Reset checkbox states
|
||||||
this.reset();
|
this.reset();
|
||||||
@@ -47,8 +49,9 @@ class BulkEdit extends Behavior {
|
|||||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||||
totalSpan.textContent = total;
|
totalSpan.textContent = total;
|
||||||
|
}
|
||||||
|
|
||||||
// Add new listeners
|
addListeners() {
|
||||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||||
@@ -57,6 +60,15 @@ class BulkEdit extends Behavior {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeListeners() {
|
||||||
|
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||||
|
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||||
|
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onToggleActive() {
|
onToggleActive() {
|
||||||
this.active = !this.active;
|
this.active = !this.active;
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
|
@@ -3,17 +3,14 @@ import { Behavior, registerBehavior } from "./index";
|
|||||||
class ConfirmButtonBehavior extends Behavior {
|
class ConfirmButtonBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
element.dataset.type = element.type;
|
|
||||||
element.dataset.name = element.name;
|
this.onClick = this.onClick.bind(this);
|
||||||
element.dataset.value = element.value;
|
element.addEventListener("click", this.onClick);
|
||||||
element.removeAttribute("type");
|
|
||||||
element.removeAttribute("name");
|
|
||||||
element.removeAttribute("value");
|
|
||||||
element.addEventListener("click", this.onClick.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
Behavior.interacting = false;
|
this.reset();
|
||||||
|
this.element.removeEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(event) {
|
onClick(event) {
|
||||||
@@ -53,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
|
|||||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
const confirmButton = document.createElement(this.element.nodeName);
|
const confirmButton = document.createElement(this.element.nodeName);
|
||||||
confirmButton.type = this.element.dataset.type;
|
confirmButton.type = this.element.type;
|
||||||
confirmButton.name = this.element.dataset.name;
|
confirmButton.name = this.element.name;
|
||||||
confirmButton.value = this.element.dataset.value;
|
confirmButton.value = this.element.value;
|
||||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||||
confirmButton.className = buttonClasses;
|
confirmButton.className = buttonClasses;
|
||||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||||
@@ -70,7 +67,10 @@ class ConfirmButtonBehavior extends Behavior {
|
|||||||
reset() {
|
reset() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
Behavior.interacting = false;
|
Behavior.interacting = false;
|
||||||
|
if (this.container) {
|
||||||
this.container.remove();
|
this.container.remove();
|
||||||
|
this.container = null;
|
||||||
|
}
|
||||||
this.element.classList.remove("d-none");
|
this.element.classList.remove("d-none");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
62
bookmarks/frontend/behaviors/details-modal.js
Normal file
62
bookmarks/frontend/behaviors/details-modal.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class DetailsModalBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
|
||||||
|
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||||
|
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||||
|
|
||||||
|
this.overlayLink.addEventListener("click", this.onClose);
|
||||||
|
this.buttonLink.addEventListener("click", this.onClose);
|
||||||
|
document.addEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.overlayLink.removeEventListener("click", this.onClose);
|
||||||
|
this.buttonLink.removeEventListener("click", this.onClose);
|
||||||
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget =
|
||||||
|
targetNodeName === "INPUT" ||
|
||||||
|
targetNodeName === "SELECT" ||
|
||||||
|
targetNodeName === "TEXTAREA";
|
||||||
|
|
||||||
|
if (isInputTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
this.onClose(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.element.classList.add("closing");
|
||||||
|
this.element.addEventListener(
|
||||||
|
"animationend",
|
||||||
|
(event) => {
|
||||||
|
if (event.animationName === "fade-out") {
|
||||||
|
this.element.remove();
|
||||||
|
|
||||||
|
const closeUrl = this.overlayLink.href;
|
||||||
|
Turbo.visit(closeUrl, {
|
||||||
|
action: "replace",
|
||||||
|
frame: "details-modal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-details-modal", DetailsModalBehavior);
|
@@ -4,16 +4,16 @@ class DropdownBehavior extends Behavior {
|
|||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||||
|
|
||||||
const toggle = element.querySelector(".dropdown-toggle");
|
this.toggle = element.querySelector(".dropdown-toggle");
|
||||||
toggle.addEventListener("click", () => {
|
this.toggle.addEventListener("click", this.onClick);
|
||||||
if (this.opened) {
|
|
||||||
this.close();
|
|
||||||
} else {
|
|
||||||
this.open();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
destroy() {
|
||||||
|
this.close();
|
||||||
|
this.toggle.removeEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
@@ -26,6 +26,14 @@ class DropdownBehavior extends Behavior {
|
|||||||
document.removeEventListener("click", this.onOutsideClick);
|
document.removeEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
if (this.opened) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onOutsideClick(event) {
|
onOutsideClick(event) {
|
||||||
if (!this.element.contains(event.target)) {
|
if (!this.element.contains(event.target)) {
|
||||||
this.close();
|
this.close();
|
||||||
|
@@ -1,48 +0,0 @@
|
|||||||
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
|
||||||
|
|
||||||
class FetchBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
const eventName = element.getAttribute("ld-on");
|
|
||||||
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
|
||||||
|
|
||||||
this.onFetch = this.onFetch.bind(this);
|
|
||||||
this.onInterval = this.onInterval.bind(this);
|
|
||||||
|
|
||||||
element.addEventListener(eventName, this.onFetch);
|
|
||||||
if (interval) {
|
|
||||||
this.intervalId = setInterval(this.onInterval, interval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.intervalId) {
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onFetch(maybeEvent) {
|
|
||||||
if (maybeEvent) {
|
|
||||||
maybeEvent.preventDefault();
|
|
||||||
}
|
|
||||||
const url = this.element.getAttribute("ld-fetch");
|
|
||||||
const html = await fetch(url).then((response) => response.text());
|
|
||||||
|
|
||||||
const target = this.element.getAttribute("ld-target");
|
|
||||||
const select = this.element.getAttribute("ld-select");
|
|
||||||
swap(this.element, html, { target, select });
|
|
||||||
|
|
||||||
const events = this.element.getAttribute("ld-fire");
|
|
||||||
fireEvents(events);
|
|
||||||
}
|
|
||||||
|
|
||||||
onInterval() {
|
|
||||||
if (Behavior.interacting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.onFetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-fetch", FetchBehavior);
|
|
@@ -1,64 +1,55 @@
|
|||||||
import { Behavior, fireEvents, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
class FormBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
element.addEventListener("submit", this.onSubmit.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
async onSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const url = this.element.action;
|
|
||||||
const formData = new FormData(this.element);
|
|
||||||
if (event.submitter) {
|
|
||||||
formData.append(event.submitter.name, event.submitter.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
redirect: "manual", // ignore redirect
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = this.element.getAttribute("ld-fire");
|
|
||||||
if (fireEvents) {
|
|
||||||
fireEvents(events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AutoSubmitBehavior extends Behavior {
|
class AutoSubmitBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
element.addEventListener("change", () => {
|
this.submit = this.submit.bind(this);
|
||||||
const form = element.closest("form");
|
element.addEventListener("change", this.submit);
|
||||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
}
|
||||||
});
|
|
||||||
|
destroy() {
|
||||||
|
this.element.removeEventListener("change", this.submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
this.element.closest("form").requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadButton extends Behavior {
|
class UploadButton extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
this.fileInput = element.nextElementSibling;
|
||||||
|
|
||||||
const fileInput = element.nextElementSibling;
|
this.onClick = this.onClick.bind(this);
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
|
||||||
element.addEventListener("click", () => {
|
element.addEventListener("click", this.onClick);
|
||||||
fileInput.click();
|
this.fileInput.addEventListener("change", this.onChange);
|
||||||
});
|
}
|
||||||
|
|
||||||
fileInput.addEventListener("change", () => {
|
destroy() {
|
||||||
const form = fileInput.closest("form");
|
this.element.removeEventListener("click", this.onClick);
|
||||||
const event = new Event("submit", { cancelable: true });
|
this.fileInput.removeEventListener("change", this.onChange);
|
||||||
event.submitter = element;
|
}
|
||||||
form.dispatchEvent(event);
|
|
||||||
});
|
onClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange() {
|
||||||
|
// Check if the file input has a file selected
|
||||||
|
if (!this.fileInput.files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = this.fileInput.closest("form");
|
||||||
|
form.requestSubmit(this.element);
|
||||||
|
// remove selected file so it doesn't get submitted again
|
||||||
|
this.fileInput.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerBehavior("ld-form", FormBehavior);
|
|
||||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||||
registerBehavior("ld-upload-button", UploadButton);
|
registerBehavior("ld-upload-button", UploadButton);
|
||||||
|
@@ -4,7 +4,12 @@ class GlobalShortcuts extends Behavior {
|
|||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
document.addEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown(event) {
|
onKeyDown(event) {
|
||||||
|
@@ -16,9 +16,17 @@ const mutationObserver = new MutationObserver((mutations) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
mutationObserver.observe(document.body, {
|
document.addEventListener("turbo:load", () => {
|
||||||
|
mutationObserver.observe(document.body, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyBehaviors(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("turbo:before-cache", () => {
|
||||||
|
destroyBehaviors(document.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
export class Behavior {
|
export class Behavior {
|
||||||
@@ -95,51 +103,3 @@ export function destroyBehaviors(element) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function swap(element, html, options) {
|
|
||||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
|
||||||
|
|
||||||
let targetElement = element;
|
|
||||||
let strategy = "innerHTML";
|
|
||||||
if (options.target) {
|
|
||||||
const parts = options.target.split("|");
|
|
||||||
targetElement =
|
|
||||||
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
|
||||||
strategy = parts[1] || "innerHTML";
|
|
||||||
}
|
|
||||||
|
|
||||||
let contents = Array.from(dom.body.children);
|
|
||||||
if (options.select) {
|
|
||||||
contents = Array.from(dom.querySelectorAll(options.select));
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (strategy) {
|
|
||||||
case "append":
|
|
||||||
targetElement.append(...contents);
|
|
||||||
break;
|
|
||||||
case "outerHTML":
|
|
||||||
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
|
||||||
break;
|
|
||||||
case "innerHTML":
|
|
||||||
default:
|
|
||||||
Array.from(targetElement.children).forEach((child) => {
|
|
||||||
child.remove();
|
|
||||||
});
|
|
||||||
targetElement.append(...contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fireEvents(events) {
|
|
||||||
if (!events) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
events.split(",").forEach((eventName) => {
|
|
||||||
const targets = Array.from(
|
|
||||||
document.querySelectorAll(`[ld-on='${eventName}']`),
|
|
||||||
);
|
|
||||||
targets.push(document);
|
|
||||||
targets.forEach((target) => {
|
|
||||||
target.dispatchEvent(new CustomEvent(eventName));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
@@ -1,51 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class ModalBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.onClose = this.onClose.bind(this);
|
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
|
||||||
|
|
||||||
const modalOverlay = element.querySelector(".modal-overlay");
|
|
||||||
const closeButton = element.querySelector("button.close");
|
|
||||||
modalOverlay.addEventListener("click", this.onClose);
|
|
||||||
closeButton.addEventListener("click", this.onClose);
|
|
||||||
|
|
||||||
document.addEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeyDown(event) {
|
|
||||||
// Skip if event occurred within an input element
|
|
||||||
const targetNodeName = event.target.nodeName;
|
|
||||||
const isInputTarget =
|
|
||||||
targetNodeName === "INPUT" ||
|
|
||||||
targetNodeName === "SELECT" ||
|
|
||||||
targetNodeName === "TEXTAREA";
|
|
||||||
|
|
||||||
if (isInputTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
this.onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
this.element.classList.add("closing");
|
|
||||||
this.element.addEventListener("animationend", (event) => {
|
|
||||||
if (event.animationName === "fade-out") {
|
|
||||||
this.element.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-modal", ModalBehavior);
|
|
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||||
|
|
||||||
|
class SearchAutocomplete extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
const input = element.querySelector("input");
|
||||||
|
if (!input) {
|
||||||
|
console.warn("SearchAutocomplete: input element not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
|
||||||
|
new SearchAutoCompleteComponent({
|
||||||
|
target: container,
|
||||||
|
props: {
|
||||||
|
name: "q",
|
||||||
|
placeholder: input.getAttribute("placeholder") || "",
|
||||||
|
value: input.value,
|
||||||
|
linkTarget: input.dataset.linkTarget,
|
||||||
|
mode: input.dataset.mode,
|
||||||
|
search: {
|
||||||
|
user: input.dataset.user,
|
||||||
|
shared: input.dataset.shared,
|
||||||
|
unread: input.dataset.unread,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input = input;
|
||||||
|
this.autocomplete = container.firstElementChild;
|
||||||
|
input.replaceWith(this.autocomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.autocomplete.replaceWith(this.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-search-autocomplete", SearchAutocomplete);
|
@@ -1,27 +1,35 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||||
import { ApiClient } from "../api";
|
|
||||||
|
|
||||||
class TagAutocomplete extends Behavior {
|
class TagAutocomplete extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
const wrapper = document.createElement("div");
|
const input = element.querySelector("input");
|
||||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
if (!input) {
|
||||||
const apiClient = new ApiClient(apiBaseUrl);
|
console.warn("TagAutocomplete: input element not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
|
||||||
new TagAutoCompleteComponent({
|
new TagAutoCompleteComponent({
|
||||||
target: wrapper,
|
target: container,
|
||||||
props: {
|
props: {
|
||||||
id: element.id,
|
id: input.id,
|
||||||
name: element.name,
|
name: input.name,
|
||||||
value: element.value,
|
value: input.value,
|
||||||
placeholder: element.getAttribute("placeholder") || "",
|
placeholder: input.getAttribute("placeholder") || "",
|
||||||
apiClient: apiClient,
|
variant: input.getAttribute("variant"),
|
||||||
variant: element.getAttribute("variant"),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
element.replaceWith(wrapper.firstElementChild);
|
this.input = input;
|
||||||
|
this.autocomplete = container.firstElementChild;
|
||||||
|
input.replaceWith(this.autocomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.autocomplete.replaceWith(this.input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
68
bookmarks/frontend/behaviors/tag-modal.js
Normal file
68
bookmarks/frontend/behaviors/tag-modal.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class TagModalBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
|
||||||
|
element.addEventListener("click", this.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.onClose();
|
||||||
|
this.element.removeEventListener("click", this.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.classList.add("modal", "active");
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-overlay" aria-label="Close"></div>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<button class="close" aria-label="Close">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M18 6l-12 12"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tagCloud = document.querySelector(".tag-cloud");
|
||||||
|
const tagCloudContainer = tagCloud.parentElement;
|
||||||
|
|
||||||
|
const content = modal.querySelector(".content");
|
||||||
|
content.appendChild(tagCloud);
|
||||||
|
|
||||||
|
const overlay = modal.querySelector(".modal-overlay");
|
||||||
|
const closeButton = modal.querySelector(".close");
|
||||||
|
overlay.addEventListener("click", this.onClose);
|
||||||
|
closeButton.addEventListener("click", this.onClose);
|
||||||
|
|
||||||
|
this.modal = modal;
|
||||||
|
this.tagCloud = tagCloud;
|
||||||
|
this.tagCloudContainer = tagCloudContainer;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
if (!this.modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modal.remove();
|
||||||
|
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-tag-modal", TagModalBehavior);
|
35
bookmarks/frontend/cache.js
Normal file
35
bookmarks/frontend/cache.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { api } from "./api.js";
|
||||||
|
|
||||||
|
class Cache {
|
||||||
|
constructor(api) {
|
||||||
|
this.api = api;
|
||||||
|
|
||||||
|
// Reset cached tags after a form submission
|
||||||
|
document.addEventListener("turbo:submit-end", () => {
|
||||||
|
this.tagsPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTags() {
|
||||||
|
if (!this.tagsPromise) {
|
||||||
|
this.tagsPromise = this.api
|
||||||
|
.getTags({
|
||||||
|
limit: 5000,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
.then((tags) =>
|
||||||
|
tags.sort((left, right) =>
|
||||||
|
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn("Cache: Error loading tags", e);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = new Cache(api);
|
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import {SearchHistory} from "./SearchHistory";
|
import {SearchHistory} from "./SearchHistory";
|
||||||
|
import {api} from "../api";
|
||||||
|
import {cache} from "../cache";
|
||||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||||
|
|
||||||
const searchHistory = new SearchHistory()
|
const searchHistory = new SearchHistory()
|
||||||
@@ -7,9 +9,7 @@
|
|||||||
export let name;
|
export let name;
|
||||||
export let placeholder;
|
export let placeholder;
|
||||||
export let value;
|
export let value;
|
||||||
export let tags;
|
|
||||||
export let mode = '';
|
export let mode = '';
|
||||||
export let apiClient;
|
|
||||||
export let search;
|
export let search;
|
||||||
export let linkTarget = '_blank';
|
export let linkTarget = '_blank';
|
||||||
|
|
||||||
@@ -88,17 +88,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tag suggestions
|
// Tag suggestions
|
||||||
|
const tags = await cache.getTags();
|
||||||
let tagSuggestions = []
|
let tagSuggestions = []
|
||||||
const currentWord = getCurrentWord(input)
|
const currentWord = getCurrentWord(input)
|
||||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||||
const searchTag = currentWord.substring(1, currentWord.length)
|
const searchTag = currentWord.substring(1, currentWord.length)
|
||||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(tagName => ({
|
.map(tag => ({
|
||||||
type: 'tag',
|
type: 'tag',
|
||||||
index: nextIndex(),
|
index: nextIndex(),
|
||||||
label: `#${tagName}`,
|
label: `#${tag.name}`,
|
||||||
tagName: tagName
|
tagName: tag.name
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@
|
|||||||
...search,
|
...search,
|
||||||
q: value
|
q: value
|
||||||
}
|
}
|
||||||
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||||
const label = clampText(fullLabel, 60)
|
const label = clampText(fullLabel, 60)
|
||||||
|
@@ -1,14 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import {cache} from "../cache";
|
||||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||||
|
|
||||||
export let id;
|
export let id;
|
||||||
export let name;
|
export let name;
|
||||||
export let value;
|
export let value;
|
||||||
export let placeholder;
|
export let placeholder;
|
||||||
export let apiClient;
|
|
||||||
export let variant = 'default';
|
export let variant = 'default';
|
||||||
|
|
||||||
let tags = [];
|
|
||||||
let isFocus = false;
|
let isFocus = false;
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let input = null;
|
let input = null;
|
||||||
@@ -17,18 +16,6 @@
|
|||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
let selectedIndex = 0;
|
let selectedIndex = 0;
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
// For now we cache all tags on load as the template did before
|
|
||||||
try {
|
|
||||||
tags = await apiClient.getTags({limit: 5000, offset: 0});
|
|
||||||
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('TagAutocomplete: Error loading tag list');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
isFocus = true;
|
isFocus = true;
|
||||||
}
|
}
|
||||||
@@ -38,9 +25,10 @@
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput(e) {
|
async function handleInput(e) {
|
||||||
input = e.target;
|
input = e.target;
|
||||||
|
|
||||||
|
const tags = await cache.getTags();
|
||||||
const word = getCurrentWord(input);
|
const word = getCurrentWord(input);
|
||||||
|
|
||||||
suggestions = word
|
suggestions = word
|
||||||
|
@@ -1,12 +1,16 @@
|
|||||||
|
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";
|
||||||
import "./behaviors/dropdown";
|
import "./behaviors/dropdown";
|
||||||
import "./behaviors/fetch";
|
|
||||||
import "./behaviors/form";
|
import "./behaviors/form";
|
||||||
import "./behaviors/modal";
|
import "./behaviors/details-modal";
|
||||||
import "./behaviors/global-shortcuts";
|
import "./behaviors/global-shortcuts";
|
||||||
|
import "./behaviors/search-autocomplete";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./behaviors/tag-autocomplete";
|
||||||
|
import "./behaviors/tag-modal";
|
||||||
|
|
||||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||||
export { ApiClient } from "./api";
|
export { api } from "./api";
|
||||||
|
export { cache } from "./cache";
|
||||||
|
@@ -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:
|
||||||
@@ -278,7 +282,7 @@ class BookmarkSearchForm(forms.Form):
|
|||||||
]
|
]
|
||||||
|
|
||||||
q = forms.CharField()
|
q = forms.CharField()
|
||||||
user = forms.ChoiceField()
|
user = forms.ChoiceField(required=False)
|
||||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
129
bookmarks/styles/bookmark-details.css
Normal file
129
bookmarks/styles/bookmark-details.css
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* Common styles */
|
||||||
|
.bookmark-details {
|
||||||
|
& .weblinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a.weblink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a.weblink img, & a.weblink svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a.weblink span {
|
||||||
|
flex: 1 1 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .preview-image {
|
||||||
|
margin: var(--unit-4 0);
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& dl {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .assets {
|
||||||
|
margin-top: var(--unit-2);
|
||||||
|
|
||||||
|
& .asset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
padding: var(--unit-2) 0;
|
||||||
|
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset:last-child {
|
||||||
|
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-text {
|
||||||
|
flex: 1 1 0;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-text .truncate {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-text .filesize {
|
||||||
|
color: var(--tertiary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .asset-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& .btn.btn-link {
|
||||||
|
height: unset;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .assets-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--unit-2);
|
||||||
|
|
||||||
|
& .btn.btn-link {
|
||||||
|
height: unset;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .tags a {
|
||||||
|
color: var(--alternative-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .status form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .status .form-group, .status .form-switch {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details view specific */
|
||||||
|
.bookmark-details.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--unit-6);
|
||||||
|
}
|
@@ -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);
|
||||||
|
}
|
264
bookmarks/styles/theme/buttons.css
Normal file
264
bookmarks/styles/theme/buttons.css
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/* Buttons */
|
||||||
|
:root {
|
||||||
|
--btn-bg-color: var(--body-color);
|
||||||
|
--btn-hover-bg-color: var(--gray-50);
|
||||||
|
--btn-border-color: var(--border-color);
|
||||||
|
--btn-text-color: var(--text-color);
|
||||||
|
--btn-icon-color: var(--icon-color);
|
||||||
|
--btn-font-weight: 400;
|
||||||
|
--btn-box-shadow: var(--box-shadow-xs);
|
||||||
|
|
||||||
|
--btn-primary-bg-color: var(--primary-color);
|
||||||
|
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||||
|
--btn-primary-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-success-bg-color: var(--success-color);
|
||||||
|
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||||
|
--btn-success-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-error-bg-color: var(--error-color);
|
||||||
|
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||||
|
--btn-error-text-color: var(--contrast-text-color);
|
||||||
|
|
||||||
|
--btn-link-text-color: var(--link-color);
|
||||||
|
--btn-link-hover-text-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--btn-bg-color);
|
||||||
|
border: var(--border-width) solid var(--btn-border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--btn-text-color);
|
||||||
|
font-weight: var(--btn-font-weight);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-size);
|
||||||
|
height: var(--control-size);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
outline: none;
|
||||||
|
padding: var(--control-padding-y) var(--control-padding-x);
|
||||||
|
box-shadow: var(--btn-box-shadow);
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, border 0.2s, box-shadow 0.2s, color 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: var(--focus-outline-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--btn-hover-bg-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Primary */
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--btn-primary-bg-color);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--btn-primary-text-color);
|
||||||
|
--btn-icon-color: var(--btn-primary-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--btn-primary-hover-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
&::after {
|
||||||
|
border-bottom-color: var(--btn-primary-text-color);
|
||||||
|
border-left-color: var(--btn-primary-text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Colors */
|
||||||
|
|
||||||
|
&.btn-success {
|
||||||
|
background: var(--btn-success-bg-color);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--btn-success-text-color);
|
||||||
|
--btn-icon-color: var(--btn-success-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--btn-success-hover-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-error {
|
||||||
|
--btn-border-color: var(--error-color);
|
||||||
|
--btn-text-color: var(--error-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--btn-hover-bg-color: var(--error-color-shade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Link */
|
||||||
|
|
||||||
|
&.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--btn-link-text-color);
|
||||||
|
--btn-icon-color: var(--btn-link-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--btn-link-hover-text-color);
|
||||||
|
--btn-icon-color: var(--btn-link-hover-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Sizes */
|
||||||
|
|
||||||
|
&.btn-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: var(--control-size-sm);
|
||||||
|
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
height: var(--control-size-lg);
|
||||||
|
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Block */
|
||||||
|
|
||||||
|
&.btn-block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Action */
|
||||||
|
|
||||||
|
&.btn-action {
|
||||||
|
width: var(--control-size);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
|
||||||
|
&.btn-sm {
|
||||||
|
width: var(--control-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-lg {
|
||||||
|
width: var(--control-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Clear */
|
||||||
|
|
||||||
|
&.btn-clear {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: currentColor;
|
||||||
|
box-shadow: none;
|
||||||
|
height: var(--unit-5);
|
||||||
|
line-height: var(--unit-4);
|
||||||
|
margin-left: var(--unit-1);
|
||||||
|
margin-right: -2px;
|
||||||
|
opacity: 1;
|
||||||
|
padding: var(--unit-h);
|
||||||
|
text-decoration: none;
|
||||||
|
width: var(--unit-5);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "\2715";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider button */
|
||||||
|
|
||||||
|
&.btn-wide {
|
||||||
|
padding-left: var(--unit-6);
|
||||||
|
padding-right: var(--unit-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small icon button */
|
||||||
|
|
||||||
|
&.btn-sm.btn-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--unit-h);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button icons */
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
color: var(--btn-icon-color);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button groups */
|
||||||
|
.btn-group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
|
||||||
|
&:first-child:not(:last-child) {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child):not(:last-child) {
|
||||||
|
border-radius: 0;
|
||||||
|
margin-left: calc(-1 * var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child:not(:first-child) {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
margin-left: calc(-1 * var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
z-index: var(--zindex-0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-group-block {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
103
bookmarks/styles/theme/modals.css
Normal file
103
bookmarks/styles/theme/modals.css
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/* 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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
& h2 {
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& button.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .85;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .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);
|
|
@@ -11,24 +11,20 @@
|
|||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Archived bookmarks</h2>
|
<h2>Archived bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
|
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||||
class="btn ml-2 show-md">Tags
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
<form class="bookmark-actions"
|
||||||
class="bookmark-actions"
|
|
||||||
action="{{ bookmark_list.action_url|safe }}"
|
action="{{ bookmark_list.action_url|safe }}"
|
||||||
method="post" autocomplete="off">
|
method="post" autocomplete="off">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||||
|
|
||||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
<div id="bookmark-list-container">
|
||||||
ld-fire="refresh-bookmark-list-done"
|
|
||||||
class="bookmark-list-container">
|
|
||||||
{% include 'bookmarks/bookmark_list.html' %}
|
{% include 'bookmarks/bookmark_list.html' %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -39,10 +35,16 @@
|
|||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
<div id="tag-cloud-container">
|
||||||
class="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -10,13 +10,14 @@
|
|||||||
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="content">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<label class="form-checkbox bulk-edit-checkbox">
|
<label class="form-checkbox bulk-edit-checkbox">
|
||||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
</label>
|
</label>
|
||||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||||
<span>{{ bookmark_item.title }}</span>
|
<span>{{ bookmark_item.title }}</span>
|
||||||
@@ -46,9 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if bookmark_item.description %}
|
{% if bookmark_item.description %}
|
||||||
<div class="description separate">
|
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||||
{{ bookmark_item.description }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.tag_names %}
|
{% if bookmark_item.tag_names %}
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
@@ -59,20 +58,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.notes %}
|
{% if bookmark_item.notes %}
|
||||||
<div class="notes bg-gray text-gray-dark">
|
<div class="notes">
|
||||||
<div class="markdown">
|
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||||
{% markdown bookmark_item.notes %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions text-gray">
|
<div class="actions">
|
||||||
{% if bookmark_item.display_date %}
|
{% if bookmark_item.display_date %}
|
||||||
{% if bookmark_item.web_archive_snapshot_url %}
|
{% if bookmark_item.web_archive_snapshot_url %}
|
||||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||||
target="{{ bookmark_list.link_target }}"
|
target="{{ bookmark_list.link_target }}"
|
||||||
rel="noopener">
|
rel="noopener">
|
||||||
{{ bookmark_item.display_date }} ∞
|
{{ bookmark_item.display_date }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>{{ bookmark_item.display_date }}</span>
|
<span>{{ bookmark_item.display_date }}</span>
|
||||||
@@ -81,9 +78,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{# View link is visible for both owned and shared bookmarks #}
|
{# View link is visible for both owned and shared bookmarks #}
|
||||||
{% if bookmark_list.show_view_action %}
|
{% if bookmark_list.show_view_action %}
|
||||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||||
ld-on="click" ld-target="body|append"
|
|
||||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.is_editable %}
|
{% if bookmark_item.is_editable %}
|
||||||
{# Bookmark owner actions #}
|
{# Bookmark owner actions #}
|
||||||
@@ -146,6 +141,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user