mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 14:39:24 +02:00
Compare commits
45 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
785fe32aaa | ||
![]() |
5559ad0070 | ||
![]() |
76c65566cf | ||
![]() |
c929e8f11c | ||
![]() |
3ae9cf0420 | ||
![]() |
b736464f3f | ||
![]() |
7572aa5bc9 | ||
![]() |
cb0301fd9e | ||
![]() |
b30486317d | ||
![]() |
1c6e5902db | ||
![]() |
20fe88dd57 | ||
![]() |
aad62f61c9 | ||
![]() |
79bf4b38c6 | ||
![]() |
5eadb3ede3 | ||
![]() |
36749c398b | ||
![]() |
190b5aeeca | ||
![]() |
1122d18e18 | ||
![]() |
0fe6304328 | ||
![]() |
7d4e65976f | ||
![]() |
749bc1ef63 | ||
![]() |
36a84276a2 | ||
![]() |
b72697b819 | ||
![]() |
d9362c9b9c | ||
![]() |
b0610db406 | ||
![]() |
af16a9e727 | ||
![]() |
d898c1be4d | ||
![]() |
0282220307 | ||
![]() |
bb243b382d | ||
![]() |
fbc97a3841 | ||
![]() |
380f5ed19c | ||
![]() |
b28352fb28 | ||
![]() |
695b0dc300 | ||
![]() |
fe40139838 | ||
![]() |
44b49a4cfe | ||
![]() |
469883a674 | ||
![]() |
fa5f78cf71 | ||
![]() |
e03f536925 | ||
![]() |
a92a35cfb8 | ||
![]() |
ff334e0888 | ||
![]() |
0f9ba57fef | ||
![]() |
b4376a9ff1 | ||
![]() |
87cd4061cb | ||
![]() |
e2415f652b | ||
![]() |
9cf5eb5ec0 | ||
![]() |
023a213ba6 |
@@ -10,6 +10,7 @@
|
||||
!/manage.py
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.mjs
|
||||
|
3
.github/workflows/main.yaml
vendored
3
.github/workflows/main.yaml
vendored
@@ -53,7 +53,6 @@ jobs:
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
python manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -183,7 +183,7 @@ typings/
|
||||
### Custom
|
||||
# Rollup compilation output
|
||||
/bookmarks/static/bundle.js*
|
||||
# SASS compilation output
|
||||
# CSS compilation output
|
||||
/bookmarks/static/theme-*.css*
|
||||
# Collected static files for deployment
|
||||
/static
|
||||
@@ -194,3 +194,5 @@ typings/
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
65
CHANGELOG.md
65
CHANGELOG.md
@@ -1,5 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## v1.31.1 (30/08/2024)
|
||||
|
||||
### What's Changed
|
||||
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
|
||||
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
|
||||
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
|
||||
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
|
||||
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
|
||||
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
|
||||
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
|
||||
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
|
||||
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
|
||||
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
|
||||
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
|
||||
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
|
||||
|
||||
### New Contributors
|
||||
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
|
||||
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
|
||||
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
|
||||
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
|
||||
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
|
||||
|
||||
---
|
||||
|
||||
## v1.31.0 (16/06/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
|
||||
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
|
||||
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
|
||||
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
|
||||
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
|
||||
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
|
||||
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
|
||||
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
|
||||
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
|
||||
|
||||
### New Contributors
|
||||
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
|
||||
|
||||
---
|
||||
|
||||
## v1.30.0 (20/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
|
||||
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
|
||||
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
|
||||
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
|
||||
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
|
||||
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
|
||||
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
|
||||
|
||||
### New Contributors
|
||||
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
|
||||
|
||||
---
|
||||
|
||||
## v1.29.0 (14/04/2024)
|
||||
|
||||
### What's Changed
|
||||
|
2
Makefile
2
Makefile
@@ -7,7 +7,7 @@ tasks:
|
||||
python manage.py process_tasks
|
||||
|
||||
test:
|
||||
pytest
|
||||
pytest -n auto
|
||||
|
||||
format:
|
||||
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).
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements + Donations
|
||||
|
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.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -11,6 +13,7 @@ from bookmarks.api.serializers import (
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
@@ -18,6 +21,8 @@ from bookmarks.services.bookmarks import (
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
@@ -51,7 +56,7 @@ class BookmarkViewSet(
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
return {"request": self.request, "user": self.request.user}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
@@ -59,8 +64,8 @@ class BookmarkViewSet(
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@@ -72,8 +77,8 @@ class BookmarkViewSet(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
@@ -99,13 +104,32 @@ class BookmarkViewSet(
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url, bookmark.website_title, bookmark.website_description
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
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(
|
||||
{"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,
|
||||
)
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
@@ -31,6 +32,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"shared",
|
||||
@@ -42,6 +45,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
@@ -56,6 +61,24 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
favicon_url = serializers.SerializerMethodField()
|
||||
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):
|
||||
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):
|
||||
return {"app_version": utils.app_version}
|
||||
|
@@ -85,3 +85,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
url = self.live_server_url + reverse("bookmarks:new")
|
||||
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
page.goto(url)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
||||
|
@@ -2,7 +2,8 @@ import unicodedata
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import QuerySet, prefetch_related_objects
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
|
||||
@dataclass
|
||||
class FeedContext:
|
||||
request: HttpRequest
|
||||
feed_token: FeedToken | None
|
||||
query_set: QuerySet[Bookmark]
|
||||
|
||||
@@ -26,13 +28,27 @@ def sanitize(text: str):
|
||||
|
||||
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
query_set = queries.query_bookmarks(
|
||||
feed_token.user, feed_token.user.profile, search
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
)
|
||||
return FeedContext(feed_token, query_set)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
raise NotImplementedError
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
limit = context.request.GET.get("limit", 100)
|
||||
if limit:
|
||||
data = context.query_set[: int(limit)]
|
||||
else:
|
||||
data = list(context.query_set)
|
||||
prefetch_related_objects(data, "tags")
|
||||
return data
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
return sanitize(item.resolved_title)
|
||||
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
|
||||
def item_pubdate(self, item: Bookmark):
|
||||
return item.date_added
|
||||
|
||||
def item_categories(self, item: Bookmark):
|
||||
return item.tag_names
|
||||
|
||||
|
||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "All bookmarks"
|
||||
description = "All bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Unread bookmarks"
|
||||
description = "All unread bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_bookmarks(
|
||||
feed_token.user, feed_token.user.profile, search
|
||||
).filter(unread=True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set.filter(unread=True)
|
||||
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Shared bookmarks"
|
||||
description = "All shared bookmarks"
|
||||
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_shared_bookmarks(
|
||||
None, feed_token.user.profile, search, False
|
||||
)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Public shared bookmarks"
|
||||
description = "All public shared bookmarks"
|
||||
|
||||
def get_object(self, request):
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
default_profile = UserProfile()
|
||||
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
||||
return FeedContext(None, query_set)
|
||||
return super().get_object(request, None)
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.public_shared")
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
@@ -16,9 +16,13 @@ const mutationObserver = new MutationObserver((mutations) => {
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
window.addEventListener("turbo:load", () => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
|
@@ -48,6 +48,19 @@ class Command(BaseCommand):
|
||||
file_path = os.path.join(root, 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}"))
|
||||
|
||||
def backup_database(self, backup_db_file):
|
||||
|
@@ -1,23 +1,40 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.models import UserProfile, GlobalSettings
|
||||
|
||||
|
||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||
|
||||
|
||||
class UserProfileMiddleware:
|
||||
default_global_settings = GlobalSettings()
|
||||
|
||||
standard_profile = UserProfile()
|
||||
standard_profile.enable_favicons = True
|
||||
|
||||
|
||||
class LinkdingMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# add global settings to request
|
||||
try:
|
||||
global_settings = GlobalSettings.get()
|
||||
except:
|
||||
global_settings = default_global_settings
|
||||
request.global_settings = global_settings
|
||||
|
||||
# add user profile to request
|
||||
if request.user.is_authenticated:
|
||||
request.user_profile = request.user.profile
|
||||
else:
|
||||
request.user_profile = UserProfile()
|
||||
request.user_profile.enable_favicons = True
|
||||
# check if a custom profile for guests exists, otherwise use standard profile
|
||||
if global_settings.guest_profile_user:
|
||||
request.user_profile = global_settings.guest_profile_user.profile
|
||||
else:
|
||||
request.user_profile = standard_profile
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
|
@@ -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)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, 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)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
shared = models.BooleanField(default=False)
|
||||
@@ -83,7 +84,8 @@ class Bookmark(models.Model):
|
||||
|
||||
@property
|
||||
def tag_names(self):
|
||||
return [tag.name for tag in self.tags.all()]
|
||||
names = [tag.name for tag in self.tags.all()]
|
||||
return sorted(names)
|
||||
|
||||
def __str__(self):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
@@ -168,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.instance and self.instance.notes
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
@@ -351,6 +355,12 @@ class UserProfile(models.Model):
|
||||
(TAG_SEARCH_STRICT, "Strict"),
|
||||
(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(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
@@ -391,9 +401,16 @@ class UserProfile(models.Model):
|
||||
blank=False,
|
||||
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_public_sharing = 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_view_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)
|
||||
permanent_notes = models.BooleanField(default=False, 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)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||
@@ -417,9 +435,11 @@ class UserProfileForm(forms.ModelForm):
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"tag_grouping",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"enable_preview_images",
|
||||
"enable_automatic_html_snapshots",
|
||||
"display_url",
|
||||
"display_view_bookmark_action",
|
||||
@@ -429,6 +449,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
]
|
||||
|
||||
|
||||
@@ -474,3 +495,46 @@ class FeedToken(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
|
||||
|
||||
class GlobalSettings(models.Model):
|
||||
LANDING_PAGE_LOGIN = "login"
|
||||
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
|
||||
LANDING_PAGE_CHOICES = [
|
||||
(LANDING_PAGE_LOGIN, "Login"),
|
||||
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
|
||||
]
|
||||
|
||||
landing_page = models.CharField(
|
||||
max_length=50,
|
||||
choices=LANDING_PAGE_CHOICES,
|
||||
blank=False,
|
||||
default=LANDING_PAGE_LOGIN,
|
||||
)
|
||||
guest_profile_user = models.ForeignKey(
|
||||
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
enable_link_prefetch = models.BooleanField(default=False, null=False)
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
instance = GlobalSettings.objects.first()
|
||||
if not instance:
|
||||
instance = GlobalSettings()
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk and GlobalSettings.objects.exists():
|
||||
raise Exception("There is already one instance of GlobalSettings")
|
||||
return super(GlobalSettings, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class GlobalSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GlobalSettings
|
||||
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
|
||||
self.fields["guest_profile_user"].empty_label = "Standard profile"
|
||||
|
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.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
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)
|
||||
# Load favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
@@ -58,6 +61,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
bookmark.save()
|
||||
# Update favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Update preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
if has_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):
|
||||
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)
|
||||
bookmark.tags.set(tags)
|
||||
|
||||
|
@@ -79,10 +79,10 @@ def import_netscape_html(
|
||||
for batch in batches:
|
||||
_import_batch(batch, user, options, tag_cache, result)
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
# Load favicons for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
# Load previews for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_previews(user)
|
||||
|
||||
end = timezone.now()
|
||||
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
|
||||
|
||||
|
||||
class SingeFileError(Exception):
|
||||
class SingleFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def create_snapshot(url: str, filepath: str):
|
||||
|
||||
# check if the file was created
|
||||
if not os.path.exists(temp_filepath):
|
||||
raise SingeFileError("Failed to create snapshot")
|
||||
raise SingleFileError("Failed to create snapshot")
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
@@ -47,12 +47,12 @@ def create_snapshot(url: str, filepath: str):
|
||||
)
|
||||
process.terminate()
|
||||
process.wait(timeout=20)
|
||||
raise SingeFileError("Timeout expired while creating snapshot")
|
||||
raise SingleFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the whole process group, which should also clean up any chromium
|
||||
# processes spawned by single-file
|
||||
logger.error("Timeout expired while terminating. Killing process...")
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
raise SingeFileError("Timeout expired while creating snapshot")
|
||||
raise SingleFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")
|
||||
raise SingleFileError(f"Failed to create snapshot: {error.stderr}")
|
||||
|
@@ -7,15 +7,15 @@ import waybackpy
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone, formats
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from huey.exceptions import TaskLockedException
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||
from bookmarks.services import favicon_loader, singlefile
|
||||
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _load_newest_snapshot(bookmark: Bookmark):
|
||||
try:
|
||||
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
|
||||
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
|
||||
bookmark.url
|
||||
)
|
||||
existing_snapshot = cdx_api.newest()
|
||||
|
||||
if existing_snapshot:
|
||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
||||
bookmark.save(update_fields=["web_archive_snapshot_url"])
|
||||
logger.info(
|
||||
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
|
||||
)
|
||||
|
||||
except NoCDXRecordFound:
|
||||
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
|
||||
except WaybackError as error:
|
||||
logger.error(
|
||||
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
|
||||
)
|
||||
|
||||
|
||||
def _create_snapshot(bookmark: Bookmark):
|
||||
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
||||
archive = waybackpy.WaybackMachineSaveAPI(
|
||||
@@ -116,48 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
return
|
||||
except TooManyRequestsError:
|
||||
logger.error(
|
||||
f"Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}"
|
||||
f"Failed to create snapshot due to rate limiting. url={bookmark.url}"
|
||||
)
|
||||
except WaybackError as error:
|
||||
logger.error(
|
||||
f"Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}",
|
||||
f"Failed to create snapshot. url={bookmark.url}",
|
||||
exc_info=error,
|
||||
)
|
||||
|
||||
# Load the newest snapshot as fallback
|
||||
_load_newest_snapshot(bookmark)
|
||||
|
||||
|
||||
@task()
|
||||
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
# Skip if snapshot exists
|
||||
if bookmark.web_archive_snapshot_url:
|
||||
return
|
||||
# Load the newest snapshot
|
||||
_load_newest_snapshot(bookmark)
|
||||
|
||||
|
||||
def schedule_bookmarks_without_snapshots(user: User):
|
||||
if is_web_archive_integration_active(user):
|
||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
||||
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||
# for now to prevent errors when huey tries to run the task
|
||||
pass
|
||||
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
||||
web_archive_snapshot_url__exact="", owner=user
|
||||
)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||
# new ones when processing bookmarks in bulk
|
||||
_load_web_archive_snapshot_task(bookmark.id)
|
||||
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||
# for now to prevent errors when huey tries to run the task
|
||||
pass
|
||||
|
||||
|
||||
def is_favicon_feature_active(user: User) -> bool:
|
||||
@@ -166,6 +122,12 @@ def is_favicon_feature_active(user: User) -> bool:
|
||||
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):
|
||||
if is_favicon_feature_active(user):
|
||||
_load_favicon_task(bookmark.id)
|
||||
@@ -221,6 +183,51 @@ def _schedule_refresh_favicons_task(user_id: int):
|
||||
_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:
|
||||
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
|
@@ -1,42 +1,20 @@
|
||||
import time
|
||||
from typing import Dict
|
||||
import datetime
|
||||
|
||||
import waybackpy
|
||||
import waybackpy.utils
|
||||
from waybackpy.exceptions import NoCDXRecordFound
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
||||
def generate_fallback_webarchive_url(
|
||||
url: str, timestamp: datetime.datetime
|
||||
) -> str | None:
|
||||
"""
|
||||
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
|
||||
See https://github.com/akamhy/waybackpy/issues/176
|
||||
Generate a URL to the web archive for the given URL and timestamp.
|
||||
A snapshot for the specific timestamp might not exist, in which case the
|
||||
web archive will show the closest snapshot to the given timestamp.
|
||||
If there is no snapshot at all the URL will be invalid.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
if not timestamp:
|
||||
timestamp = timezone.now()
|
||||
|
||||
def newest(self):
|
||||
unix_timestamp = int(time.time())
|
||||
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
|
||||
unix_timestamp
|
||||
)
|
||||
self.sort = "closest"
|
||||
self.limit = -5
|
||||
|
||||
newest_snapshot = None
|
||||
for snapshot in self.snapshots():
|
||||
newest_snapshot = snapshot
|
||||
break
|
||||
|
||||
if not newest_snapshot:
|
||||
raise NoCDXRecordFound(
|
||||
"Wayback Machine's CDX server did not return any records "
|
||||
+ "for the query. The URL may not have any archives "
|
||||
+ " on the Wayback Machine or the URL may have been recently "
|
||||
+ "archived and is still not available on the CDX server."
|
||||
)
|
||||
|
||||
return newest_snapshot
|
||||
|
||||
def add_payload(self, payload: Dict[str, str]) -> None:
|
||||
super().add_payload(payload)
|
||||
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
|
||||
# makes searching for latest snapshots faster
|
||||
payload["fastLatest"] = "true"
|
||||
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -15,12 +16,14 @@ class WebsiteMetadata:
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
preview_image: str | None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"preview_image": self.preview_image,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +33,7 @@ class WebsiteMetadata:
|
||||
def load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
try:
|
||||
start = timezone.now()
|
||||
page_text = load_page(url)
|
||||
@@ -55,10 +59,21 @@ def load_website_metadata(url: str):
|
||||
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()
|
||||
logger.debug(f"Parsing duration: {end - start}")
|
||||
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
|
||||
|
@@ -1,15 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import user_logged_in
|
||||
from django.db.backends.signals import connection_created
|
||||
from django.dispatch import receiver
|
||||
|
||||
from bookmarks.services import tasks
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def user_logged_in(sender, request, user, **kwargs):
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
|
||||
|
||||
@receiver(connection_created)
|
||||
def extend_sqlite(connection=None, **kwargs):
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 447 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,136 +0,0 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: $size-sm) {
|
||||
// Horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: $unit-9;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 $unit-3;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared components */
|
||||
|
||||
// Content area component
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-5;
|
||||
padding-bottom: $unit-1;
|
||||
margin-bottom: $unit-3;
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: $unit-9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm button component
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-1;
|
||||
color: $error-color !important;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional utilities */
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.btn.btn-wide {
|
||||
padding-left: $unit-6;
|
||||
padding-right: $unit-6;
|
||||
}
|
||||
|
||||
.btn.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-h;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
150
bookmarks/styles/bookmark-details.css
Normal file
150
bookmarks/styles/bookmark-details.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink img, & a.weblink svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
& a.weblink span {
|
||||
flex: 1 1 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
margin: var(--unit-4 0);
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .asset-text .filesize {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
& .asset-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .assets-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .tags a {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
|
||||
& .status form {
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .status .form-group, .status .form-switch {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark details view specific */
|
||||
.bookmark-details.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-6);
|
||||
}
|
||||
|
||||
/* Bookmark details modal specific */
|
||||
.bookmark-details.modal {
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
@@ -1,132 +0,0 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2;
|
||||
}
|
||||
|
||||
a.weblink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2;
|
||||
}
|
||||
|
||||
a.weblink img, a.weblink svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $body-font-color;
|
||||
}
|
||||
|
||||
a.weblink span {
|
||||
flex: 1 1 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.assets {
|
||||
margin-top: $unit-2;
|
||||
}
|
||||
|
||||
.assets .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3;
|
||||
padding: $unit-2 0;
|
||||
border-top: $unit-o solid $border-color-light;
|
||||
}
|
||||
|
||||
.assets .asset:last-child {
|
||||
border-bottom: $unit-o solid $border-color-light;
|
||||
}
|
||||
|
||||
.assets .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.assets .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: $unit-2;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.assets .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.assets .asset-text .filesize {
|
||||
color: $gray-color;
|
||||
}
|
||||
|
||||
.assets .asset-actions, .assets-actions {
|
||||
display: flex;
|
||||
gap: $unit-4;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.assets .asset-actions .btn, .assets-actions .btn {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.assets-actions {
|
||||
margin-top: $unit-2;
|
||||
}
|
||||
|
||||
.tags a {
|
||||
color: $alternative-color;
|
||||
}
|
||||
|
||||
.status form {
|
||||
display: flex;
|
||||
gap: $unit-2;
|
||||
}
|
||||
|
||||
.status .form-group, .status .form-switch {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark details view specific */
|
||||
.bookmark-details.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6;
|
||||
}
|
||||
|
||||
/* Bookmark details modal specific */
|
||||
.bookmark-details.modal {
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $unit-2;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
48
bookmarks/styles/bookmark-form.css
Normal file
48
bookmarks/styles/bookmark-form.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.bookmarks-form-page {
|
||||
section {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-form {
|
||||
& .btn.btn-link.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
--btn-icon-color: var(--tertiary-text-color);
|
||||
|
||||
& > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
& .has-icon-right > input, & .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
& .has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
& .has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
& .form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& .form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
& .form-input-hint.auto-tags {
|
||||
display: none;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
& details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right > input, .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
457
bookmarks/styles/bookmark-page.css
Normal file
457
bookmarks/styles/bookmark-page.css
Normal file
@@ -0,0 +1,457 @@
|
||||
:root {
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--gray-50);
|
||||
}
|
||||
|
||||
/* Bookmark page grid */
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .search-container {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
max-width: 300px;
|
||||
margin-left: auto;
|
||||
|
||||
& form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
max-width: initial;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Regular input */
|
||||
|
||||
& input[type='search'] {
|
||||
height: var(--control-size);
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Enhanced auto-complete input */
|
||||
/* This needs a bit more wrangling to make the CSS component align with the attached button */
|
||||
|
||||
& .form-autocomplete {
|
||||
height: var(--control-size);
|
||||
|
||||
& .form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: var(--control-size);
|
||||
|
||||
& input[type='search'] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Group search options button with search button */
|
||||
height: var(--control-size);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow-xs);
|
||||
|
||||
& input, & .form-autocomplete-input {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
& .dropdown-toggle {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
box-shadow: none;
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
/* Search option menu styles */
|
||||
|
||||
& .dropdown {
|
||||
& .menu {
|
||||
padding: var(--unit-4);
|
||||
min-width: 250px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .menu .actions {
|
||||
margin-top: var(--unit-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
& .form-group:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& .form-group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
|
||||
& .radio-group {
|
||||
& .form-label {
|
||||
margin-bottom: var(--unit-1);
|
||||
}
|
||||
|
||||
& .form-radio.form-inline {
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: var(--unit-1);
|
||||
}
|
||||
|
||||
& .form-icon {
|
||||
top: 0;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--unit-3);
|
||||
|
||||
& .content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& img.preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: var(--unit-h);
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--border-color);
|
||||
}
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .title img {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
& .title img + a {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
& .title a {
|
||||
color: var(--bookmark-title-color);
|
||||
font-weight: var(--bookmark-title-weight);
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .title a[data-tooltip]:hover::after, & .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: var(--unit-1);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #424a8c;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
& .title a[data-tooltip]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
& .url-path, & .url-display {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--secondary-link-color);
|
||||
}
|
||||
|
||||
& .description {
|
||||
color: var(--bookmark-description-color);
|
||||
font-weight: var(--bookmark-description-weight);
|
||||
}
|
||||
|
||||
& .description.separate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .tags {
|
||||
& a, & a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions, & .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& .extra-actions {
|
||||
width: 100%;
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions {
|
||||
color: var(--bookmark-actions-color);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
& a, & button.btn-link {
|
||||
color: var(--bookmark-actions-color);
|
||||
--btn-icon-color: var(--bookmark-actions-color);
|
||||
font-weight: var(--bookmark-actions-weight);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--bookmark-actions-hover-color);
|
||||
--btn-icon-color: var(--bookmark-actions-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
/* Remove left padding from first pagination link */
|
||||
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
& .selected-tags {
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .unselected-tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .group {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
|
||||
& .highlight-char {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--alternative-color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
& .notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: var(--unit-1) 0;
|
||||
overflow-y: auto;
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
& .notes .markdown {
|
||||
padding: var(--unit-2) var(--unit-3);
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
& li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark bulk edit */
|
||||
:root {
|
||||
--bulk-edit-toggle-width: 16px;
|
||||
--bulk-edit-toggle-offset: 8px;
|
||||
--bulk-edit-bar-offset: calc(var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset)));
|
||||
--bulk-edit-transition-duration: 400ms;
|
||||
}
|
||||
|
||||
[ld-bulk-edit] {
|
||||
& .bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
|
||||
margin-bottom: var(--unit-4);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height var(--bulk-edit-transition-duration);
|
||||
background: var(--bulk-actions-bg-color);
|
||||
}
|
||||
|
||||
&.active .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
}
|
||||
|
||||
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
|
||||
&.active section:first-of-type .content-area-header {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
||||
|
||||
&.active:not(.activating) .bulk-edit-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox.all {
|
||||
display: block;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
margin: 0 0 0 var(--bulk-edit-toggle-offset);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
|
||||
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
min-height: var(--bulk-edit-toggle-width);
|
||||
left: calc(-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset));
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all var(--bulk-edit-transition-duration);
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
& .bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--unit-1) 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
gap: var(--unit-2);
|
||||
|
||||
& button {
|
||||
--control-padding-x-sm: 0;
|
||||
}
|
||||
|
||||
& button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
& > input,
|
||||
& .form-autocomplete,
|
||||
& select {
|
||||
width: auto;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
& .select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,385 +0,0 @@
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: $unit-9;
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .content-area-header {
|
||||
--searchbox-max-width: 350px;
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
--searchbox-max-width: initial;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-page .search-container {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
// Regular input
|
||||
input[type='search'] {
|
||||
height: $control-size;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
// Enhanced auto-complete input
|
||||
// This needs a bit more wrangling to make the CSS component align with the attached button
|
||||
.form-autocomplete {
|
||||
height: $control-size;
|
||||
|
||||
.form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: $control-size;
|
||||
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1 1 0;
|
||||
min-width: var(--searchbox-min-width);
|
||||
max-width: var(--searchbox-max-width);
|
||||
}
|
||||
|
||||
.input-group > :first-child {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
// Group search options button with search button
|
||||
.input-group input[type='submit'] {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
// Search option menu styles
|
||||
.dropdown {
|
||||
.menu {
|
||||
padding: $unit-4;
|
||||
min-width: 250px;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.menu .actions {
|
||||
margin-top: $unit-4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
margin-bottom: $unit-1;
|
||||
|
||||
.form-label {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-radio.form-inline {
|
||||
margin: 0 $unit-2 0 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: $unit-1;
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
margin-top: $unit-2;
|
||||
|
||||
.form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title img {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title img + a {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.title a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title a[data-tooltip]:hover::after, .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: $unit-1;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid #424a8c;
|
||||
font-size: $font-size-sm;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.url-path, .url-display {
|
||||
font-size: $font-size-sm;
|
||||
color: $secondary-link-color;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
.description.separate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tags {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
}
|
||||
|
||||
.actions, .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-2;
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
.extra-actions {
|
||||
width: 100%;
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
font-size: $font-size-sm;
|
||||
|
||||
a, button.btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: $unit-4;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
.selected-tags {
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
a, a:visited:hover {
|
||||
color: $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
.unselected-tags {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: $unit-2;
|
||||
}
|
||||
|
||||
.highlight-char {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: $alternative-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
.notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: $unit-1 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notes .markdown {
|
||||
padding: $unit-2 $unit-3;
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark bulk edit */
|
||||
$bulk-edit-toggle-width: 16px;
|
||||
$bulk-edit-toggle-offset: 8px;
|
||||
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
||||
$bulk-edit-transition-duration: 400ms;
|
||||
|
||||
[ld-bulk-edit] {
|
||||
.bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: -$bulk-edit-bar-offset;
|
||||
margin-bottom: $unit-3;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $bulk-edit-transition-duration;
|
||||
}
|
||||
|
||||
&.active .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
|
||||
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
|
||||
&.active:not(.activating) .bulk-edit-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
.form-checkbox.bulk-edit-checkbox.all {
|
||||
display: block;
|
||||
width: $bulk-edit-toggle-width;
|
||||
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: $bulk-edit-toggle-width;
|
||||
min-height: $bulk-edit-toggle-width;
|
||||
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $unit-1 0;
|
||||
border-top: solid 1px $border-color;
|
||||
gap: $unit-2;
|
||||
|
||||
button {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
> input, .form-autocomplete, select {
|
||||
width: auto;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
}
|
65
bookmarks/styles/components.css
Normal file
65
bookmarks/styles/components.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* Shared components */
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
section.content-area .content-area-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
color: var(--error-color) !important;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-5) 0;
|
||||
}
|
||||
|
||||
/* Turbo progress bar */
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
39
bookmarks/styles/layout.css
Normal file
39
bookmarks/styles/layout.css
Normal file
@@ -0,0 +1,39 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: 600px) {
|
||||
/* Horizontal offset accounts for checkboxes that show up in bulk edit mode */
|
||||
margin: 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--unit-9);
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 var(--unit-3);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
40
bookmarks/styles/markdown.css
Normal file
40
bookmarks/styles/markdown.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.markdown {
|
||||
& p, & ul, & ol, & pre, & blockquote {
|
||||
margin: 0 0 var(--unit-2) 0;
|
||||
}
|
||||
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& ul, & ol {
|
||||
margin-left: var(--unit-4);
|
||||
}
|
||||
|
||||
& ul li, & ol li {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
|
||||
& pre {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
background-color: var(--code-bg-color);
|
||||
border-radius: var(--unit-1);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
& pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& > pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
.markdown {
|
||||
p, ul, ol, pre, blockquote {
|
||||
margin: 0 0 $unit-2 0;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: $unit-4;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: $unit-1 $unit-2;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: $unit-1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
@@ -1,10 +1,3 @@
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: $size-lg;
|
||||
}
|
||||
|
||||
.show-sm,
|
||||
.show-md {
|
||||
display: none !important;
|
||||
@@ -26,11 +19,18 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: var(--size-lg);
|
||||
}
|
||||
|
||||
.grid {
|
||||
--grid-columns: 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||
grid-gap: $unit-4;
|
||||
grid-gap: var(--unit-4);
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
@@ -46,18 +46,18 @@
|
||||
}
|
||||
|
||||
.col-1 {
|
||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||
grid-column: span min(1, var(--grid-columns));
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
grid-column: unquote("span min(2, var(--grid-columns))");
|
||||
grid-column: span min(2, var(--grid-columns));
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
grid-column: unquote("span min(3, var(--grid-columns))");
|
||||
grid-column: span min(3, var(--grid-columns));
|
||||
}
|
||||
|
||||
@media (max-width: $size-md) {
|
||||
@media (max-width: 840px) {
|
||||
.hide-md {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -86,7 +86,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
@media (max-width: 600px) {
|
||||
.hide-sm {
|
||||
display: none !important;
|
||||
}
|
26
bookmarks/styles/settings.css
Normal file
26
bookmarks/styles/settings.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: var(--unit-10);
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
}
|
||||
|
||||
textarea.monospace {
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
section.about table {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
& .form-group {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: $unit-10;
|
||||
|
||||
h2 {
|
||||
margin-bottom: $unit-3;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.custom-css {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
section.about table {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
@@ -1,197 +0,0 @@
|
||||
// Customized Spectre CSS imports, removing modules that are not used
|
||||
// See node_modules/spectre.css/src/spectre.scss for the original version
|
||||
|
||||
// Variables and mixins
|
||||
@import "../../node_modules/spectre.css/src/variables";
|
||||
|
||||
// Customize variables to reduce font and control sizes
|
||||
|
||||
// Can use CSS variables for font sizes, as they are not used in SCSS calculations
|
||||
$font-size: var(--font-size);
|
||||
$font-size-sm: var(--font-size-sm);
|
||||
$font-size-lg: var(--font-size-lg);
|
||||
|
||||
// Can't use CSS variables for these, used in SCSS calculations
|
||||
$line-height: 1rem;
|
||||
$control-size: $unit-8;
|
||||
$control-size-sm: $unit-6;
|
||||
$control-size-lg: $unit-9;
|
||||
|
||||
// Declare defaults for CSS variables, expose SCSS variables as CSS variables
|
||||
html {
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
|
||||
--control-size: #{$control-size};
|
||||
--control-size-sm: #{$control-size-sm};
|
||||
--control-size-lg: #{$control-size-lg};
|
||||
}
|
||||
|
||||
// Mixins
|
||||
@import "../../node_modules/spectre.css/src/mixins";
|
||||
|
||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
||||
// Reset and dependencies
|
||||
@import "../../node_modules/spectre.css/src/normalize";
|
||||
@import "../../node_modules/spectre.css/src/base";
|
||||
|
||||
// Elements
|
||||
@import "../../node_modules/spectre.css/src/typography";
|
||||
@import "../../node_modules/spectre.css/src/asian";
|
||||
@import "../../node_modules/spectre.css/src/tables";
|
||||
@import "../../node_modules/spectre.css/src/buttons";
|
||||
@import "../../node_modules/spectre.css/src/forms";
|
||||
@import "../../node_modules/spectre.css/src/labels";
|
||||
@import "../../node_modules/spectre.css/src/codes";
|
||||
@import "../../node_modules/spectre.css/src/media";
|
||||
|
||||
// Components
|
||||
@import "../../node_modules/spectre.css/src/badges";
|
||||
@import "../../node_modules/spectre.css/src/dropdowns";
|
||||
@import "../../node_modules/spectre.css/src/empty";
|
||||
@import "../../node_modules/spectre.css/src/menus";
|
||||
@import "../../node_modules/spectre.css/src/modals";
|
||||
@import "../../node_modules/spectre.css/src/pagination";
|
||||
@import "../../node_modules/spectre.css/src/tabs";
|
||||
@import "../../node_modules/spectre.css/src/toasts";
|
||||
@import "../../node_modules/spectre.css/src/tooltips";
|
||||
|
||||
// Utility classes
|
||||
@import "../../node_modules/spectre.css/src/animations";
|
||||
@import "../../node_modules/spectre.css/src/utilities";
|
||||
|
||||
// Auto-complete component
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
|
||||
|
||||
/* Spectre overrides / fixes */
|
||||
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
|
||||
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
|
||||
.btn {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Make code work with light and dark theme
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix padding for first menu item
|
||||
ul.menu li:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
// Add border to separate from background in dark mode
|
||||
.modal-container {
|
||||
border: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix modal header to use default color
|
||||
.modal-header {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Customize modal animation
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal.active .modal-container, .modal.active .modal-overlay {
|
||||
animation: fade-in .15s ease 1;
|
||||
}
|
||||
|
||||
.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
|
||||
animation: fade-out .15s ease 1;
|
||||
}
|
||||
|
||||
// Customize menu animation
|
||||
.dropdown .menu {
|
||||
animation: fade-in .15s ease 1;
|
||||
}
|
||||
|
||||
// Modal close button
|
||||
.modal .modal-header button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: .85;
|
||||
color: $gray-color-dark;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
143
bookmarks/styles/theme-dark.css
Normal file
143
bookmarks/styles/theme-dark.css
Normal file
@@ -0,0 +1,143 @@
|
||||
@import "theme-light.css";
|
||||
|
||||
:root {
|
||||
/* Color palette */
|
||||
--contrast-5: hsla(241, 65%, 85%, 0.06);
|
||||
--contrast-10: hsla(241, 60%, 80%, 0.14);
|
||||
--contrast-20: hsla(241, 64%, 82%, 0.23);
|
||||
--contrast-30: hsla(241, 69%, 84%, 0.32);
|
||||
--contrast-40: hsla(241, 73%, 86%, 0.41);
|
||||
--contrast-50: hsla(241, 78%, 88%, 0.5);
|
||||
--contrast-60: hsla(241, 82%, 90%, 0.58);
|
||||
--contrast-70: hsla(241, 87%, 92%, 0.69);
|
||||
--contrast-80: hsla(241, 91%, 94%, 0.8);
|
||||
--contrast-90: hsla(241, 96%, 96%, 0.9);
|
||||
|
||||
--primary-color: hsl(241, 75%, 64%);
|
||||
--primary-color-highlight: hsl(241, 75%, 68%);
|
||||
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
|
||||
|
||||
--alternative-color: hsl(179, 50%, 58%);
|
||||
--alternative-color-dark: hsl(179, 80%, 75%);
|
||||
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
|
||||
--error-color: hsl(0, 80%, 60%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-300);
|
||||
--secondary-text-color: var(--gray-400);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 82%, 82%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
|
||||
|
||||
--icon-color: var(--text-color);
|
||||
|
||||
--border-color: var(--contrast-30);
|
||||
--secondary-border-color: var(--contrast-20);
|
||||
|
||||
--body-color: hsl(241, 15%, 14%);
|
||||
--body-color-contrast: var(--contrast-10);
|
||||
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid hsl(241, 100%, 78%);
|
||||
--focus-outline-offset: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--box-shadow-xs: none;
|
||||
--box-shadow: none;
|
||||
--box-shadow-lg: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--input-bg-color: var(--contrast-5);
|
||||
--input-disabled-bg-color: var(--contrast-30);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--checkbox-bg-color: var(--contrast-10);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--contrast-30);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
|
||||
--switch-bg-color: var(--contrast-10);
|
||||
--switch-border-color: var(--border-color);
|
||||
--switch-toggle-color: var(--text-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--btn-bg-color: var(--contrast-5);
|
||||
--btn-hover-bg-color: var(--contrast-20);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
|
||||
--modal-container-bg-color: hsl(241, 20%, 20%);
|
||||
--modal-container-border-color: var(--contrast-30);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--menu-bg-color: hsl(241, 20%, 20%);
|
||||
--menu-border-color: var(--contrast-30);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: none;
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--contrast-20);
|
||||
}
|
||||
|
||||
:root {
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--contrast-5);
|
||||
}
|
@@ -1,66 +0,0 @@
|
||||
// Import custom variables
|
||||
@import "variables-dark";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
@import "reader-mode";
|
||||
|
||||
/* Dark theme overrides */
|
||||
|
||||
// Buttons
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus ring
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-input:not(:placeholder-shown):invalid,
|
||||
.form-input:not(:placeholder-shown):invalid:focus,
|
||||
.has-error .form-input,
|
||||
.form-input.is-error,
|
||||
.has-error .form-select,
|
||||
.form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-input-color;
|
||||
border-color: $dt-primary-input-color;
|
||||
}
|
||||
|
||||
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
|
||||
background: $light-color;
|
||||
}
|
||||
|
||||
.form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-input-color;
|
||||
border-color: $dt-primary-input-color;
|
||||
}
|
||||
|
||||
.form-radio input:checked + .form-icon::before {
|
||||
background: $light-color;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
30
bookmarks/styles/theme-light.css
Normal file
30
bookmarks/styles/theme-light.css
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "theme/variables.css";
|
||||
@import "theme/_normalize.css";
|
||||
@import "theme/base.css";
|
||||
@import "theme/typography.css";
|
||||
@import "theme/asian.css";
|
||||
@import "theme/tables.css";
|
||||
@import "theme/buttons.css";
|
||||
@import "theme/forms.css";
|
||||
@import "theme/code.css";
|
||||
@import "theme/dropdowns.css";
|
||||
@import "theme/menus.css";
|
||||
@import "theme/badges.css";
|
||||
@import "theme/empty.css";
|
||||
@import "theme/modals.css";
|
||||
@import "theme/pagination.css";
|
||||
@import "theme/tabs.css";
|
||||
@import "theme/toasts.css";
|
||||
@import "theme/autocomplete.css";
|
||||
@import "theme/animations.css";
|
||||
@import "theme/utilities.css";
|
||||
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
@@ -1,15 +0,0 @@
|
||||
// Import custom variables
|
||||
@import "variables-light";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
@import "reader-mode";
|
21
bookmarks/styles/theme/LICENSE
Normal file
21
bookmarks/styles/theme/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 - 2020 Yan Zhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
446
bookmarks/styles/theme/_normalize.css
Normal file
446
bookmarks/styles/theme/_normalize.css
Normal file
@@ -0,0 +1,446 @@
|
||||
/* Manually forked from Normalize.css */
|
||||
/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/**
|
||||
* 1. Change the default font family in all browsers (opinionated).
|
||||
* 2. Correct the line height in all browsers.
|
||||
* 3. Prevent adjustments of font size after orientation changes in
|
||||
* IE on Windows Phone and in iOS.
|
||||
*/
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
html {
|
||||
font-family: sans-serif; /* 1 */
|
||||
-ms-text-size-adjust: 100%; /* 3 */
|
||||
-webkit-text-size-adjust: 100%; /* 3 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
footer,
|
||||
header,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
figcaption,
|
||||
figure,
|
||||
main { /* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct margin in IE 8 (removed).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers. (removed)
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the gray background on active links in IE 10.
|
||||
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent; /* 1 */
|
||||
-webkit-text-decoration-skip: objects; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the outline on focused links when they are also active or hovered
|
||||
* in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
a:active,
|
||||
a:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify default styling of address.
|
||||
*/
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Firefox 39-.
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: var(--mono-font-family); /* 1 (changed) */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font style in Android 4.3-.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct background and color in IE 9-. (Removed)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
font-weight: 400; /* (added) */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
audio,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in iOS 4-7.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10-.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overflow in IE.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers (opinionated).
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 (changed) */
|
||||
font-size: inherit; /* 1 (changed) */
|
||||
line-height: inherit; /* 1 (changed) */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
|
||||
* controls in Android 4.
|
||||
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
html [type="button"], /* 1 */
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule (removed).
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Change the border, margin, and padding in all browsers (opinionated) (changed).
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct display in IE 9-.
|
||||
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
display: inline-block; /* 1 */
|
||||
vertical-align: baseline; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10-.
|
||||
* 2. Remove the padding in IE 10-.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||
*/
|
||||
|
||||
details, /* 1 */
|
||||
menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scripting
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
canvas {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hidden
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10-.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
38
bookmarks/styles/theme/animations.css
Normal file
38
bookmarks/styles/theme/animations.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Animations */
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(-1 * var(--unit-8)));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
43
bookmarks/styles/theme/asian.css
Normal file
43
bookmarks/styles/theme/asian.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* Optimized for East Asian CJK */
|
||||
html:lang(zh),
|
||||
html:lang(zh-Hans),
|
||||
.lang-zh,
|
||||
.lang-zh-hans {
|
||||
font-family: var(--cjk-zh-hans-font-family);
|
||||
}
|
||||
|
||||
html:lang(zh-Hant),
|
||||
.lang-zh-hant {
|
||||
font-family: var(--cjk-zh-hant-font-family);
|
||||
}
|
||||
|
||||
html:lang(ja),
|
||||
.lang-ja {
|
||||
font-family: var(--cjk-jp-font-family);
|
||||
}
|
||||
|
||||
html:lang(ko),
|
||||
.lang-ko {
|
||||
font-family: var(--cjk-ko-font-family);
|
||||
}
|
||||
|
||||
:lang(zh),
|
||||
:lang(ja),
|
||||
.lang-cjk {
|
||||
& ins,
|
||||
& u {
|
||||
border-bottom: var(--border-width) solid;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
& del + del,
|
||||
& del + s,
|
||||
& ins + ins,
|
||||
& ins + u,
|
||||
& s + del,
|
||||
& s + s,
|
||||
& u + ins,
|
||||
& u + u {
|
||||
margin-left: .125em;
|
||||
}
|
||||
}
|
55
bookmarks/styles/theme/autocomplete.css
Normal file
55
bookmarks/styles/theme/autocomplete.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Autocomplete */
|
||||
.form-autocomplete {
|
||||
position: relative;
|
||||
|
||||
& .form-autocomplete-input {
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .menu {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
|
||||
& .menu-item.selected > a, & .menu-item > a:hover {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
|
||||
& .group-item, & .group-item:hover {
|
||||
color: var(--tertiary-text-color);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
64
bookmarks/styles/theme/badges.css
Normal file
64
bookmarks/styles/theme/badges.css
Normal file
@@ -0,0 +1,64 @@
|
||||
/* Badges */
|
||||
.badge {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-badge],
|
||||
&:not([data-badge]) {
|
||||
&::after {
|
||||
background: var(--primary-color);
|
||||
background-clip: padding-box;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 0 0 1px var(--body-color);
|
||||
color: var(--contrast-text-color);
|
||||
content: attr(data-badge);
|
||||
display: inline-block;
|
||||
transform: translate(-.05rem, -.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-badge] {
|
||||
&::after {
|
||||
font-size: var(--font-size-sm);
|
||||
height: .9rem;
|
||||
line-height: 1;
|
||||
min-width: .9rem;
|
||||
padding: .1rem .2rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-badge]),
|
||||
&[data-badge=""] {
|
||||
&::after {
|
||||
height: 6px;
|
||||
min-width: 6px;
|
||||
padding: 0;
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Buttons */
|
||||
|
||||
&.btn {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Avatars */
|
||||
|
||||
&.avatar {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 14.64%;
|
||||
right: 14.64%;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: var(--zindex-1);
|
||||
}
|
||||
}
|
||||
}
|
61
bookmarks/styles/theme/base.css
Normal file
61
bookmarks/styles/theme/base.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Base */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
html {
|
||||
scrollbar-gutter: initial;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--body-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--body-font-family);
|
||||
font-size: var(--font-size);
|
||||
overflow-x: hidden;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:hover,
|
||||
a:active,
|
||||
a.active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
257
bookmarks/styles/theme/buttons.css
Normal file
257
bookmarks/styles/theme/buttons.css
Normal file
@@ -0,0 +1,257 @@
|
||||
/* Buttons */
|
||||
:root {
|
||||
--btn-bg-color: var(--body-color);
|
||||
--btn-hover-bg-color: var(--gray-50);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
background: var(--btn-bg-color);
|
||||
border: var(--border-width) solid var(--btn-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--btn-text-color);
|
||||
font-weight: var(--btn-font-weight);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
box-shadow: var(--btn-box-shadow);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, border 0.2s, box-shadow 0.2s, color 0.2s;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-hover-bg-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Button Primary */
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--btn-primary-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-primary-text-color);
|
||||
--btn-icon-color: var(--btn-primary-text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-primary-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Colors */
|
||||
|
||||
&.btn-success {
|
||||
background: var(--btn-success-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-success-text-color);
|
||||
--btn-icon-color: var(--btn-success-text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-success-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-error {
|
||||
--btn-border-color: var(--error-color);
|
||||
--btn-text-color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
--btn-hover-bg-color: var(--error-color-shade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--btn-link-text-color);
|
||||
--btn-icon-color: var(--btn-link-text-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-link-hover-text-color);
|
||||
--btn-icon-color: var(--btn-link-hover-text-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
||||
&.btn-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
/* Button Block */
|
||||
|
||||
&.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Button Action */
|
||||
|
||||
&.btn-action {
|
||||
width: var(--control-size);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
&.btn-sm {
|
||||
width: var(--control-size-sm);
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
width: var(--control-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Clear */
|
||||
|
||||
&.btn-clear {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: currentColor;
|
||||
box-shadow: none;
|
||||
height: var(--unit-5);
|
||||
line-height: var(--unit-4);
|
||||
margin-left: var(--unit-1);
|
||||
margin-right: -2px;
|
||||
opacity: 1;
|
||||
padding: var(--unit-h);
|
||||
text-decoration: none;
|
||||
width: var(--unit-5);
|
||||
|
||||
&::before {
|
||||
content: "\2715";
|
||||
}
|
||||
}
|
||||
|
||||
/* Wider button */
|
||||
|
||||
&.btn-wide {
|
||||
padding-left: var(--unit-6);
|
||||
padding-right: var(--unit-6);
|
||||
}
|
||||
|
||||
/* Small icon button */
|
||||
|
||||
&.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-h);
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button icons */
|
||||
|
||||
& svg {
|
||||
color: var(--btn-icon-color);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button groups */
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 auto;
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-group-block {
|
||||
display: flex;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
}
|
||||
}
|
30
bookmarks/styles/theme/code.css
Normal file
30
bookmarks/styles/theme/code.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Code */
|
||||
:root {
|
||||
--code-bg-color: var(--body-color-contrast);
|
||||
--code-color: var(--text-color);
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 1.25;
|
||||
padding: .1rem .2rem;
|
||||
background: var(--code-bg-color);
|
||||
color: var(--code-color);
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.code {
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--code-bg-color);
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
|
||||
& code {
|
||||
color: inherit;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
padding: var(--unit-2);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
36
bookmarks/styles/theme/dropdowns.css
Normal file
36
bookmarks/styles/theme/dropdowns.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
.menu {
|
||||
animation: fade-in .15s ease 1;
|
||||
display: none;
|
||||
left: 0;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
&.dropdown-right {
|
||||
.menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu,
|
||||
.dropdown-toggle:focus + .menu,
|
||||
.menu:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fix dropdown-toggle border radius in button groups */
|
||||
.btn-group {
|
||||
.dropdown-toggle:nth-last-child(2) {
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
21
bookmarks/styles/theme/empty.css
Normal file
21
bookmarks/styles/theme/empty.css
Normal file
@@ -0,0 +1,21 @@
|
||||
/* Empty states (or Blank slates) */
|
||||
.empty {
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
padding: var(--unit-16) var(--unit-8);
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
.empty-title,
|
||||
.empty-subtitle {
|
||||
margin: var(--layout-spacing) auto;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: var(--layout-spacing-lg);
|
||||
}
|
||||
}
|
515
bookmarks/styles/theme/forms.css
Normal file
515
bookmarks/styles/theme/forms.css
Normal file
@@ -0,0 +1,515 @@
|
||||
/* Forms */
|
||||
:root {
|
||||
--input-bg-color: var(--body-color);
|
||||
--input-disabled-bg-color: var(--gray-100);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--checkbox-bg-color: var(--body-color);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--gray-100);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
|
||||
--switch-bg-color: var(--gray-300);
|
||||
--switch-border-color: var(--gray-400);
|
||||
--switch-toggle-color: #fff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&:first-of-type {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
legend {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
}
|
||||
|
||||
/* Form element: Label */
|
||||
.form-label {
|
||||
display: block;
|
||||
line-height: var(--line-height);
|
||||
margin-bottom: var(--unit-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
details summary .form-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
details[open] summary .form-label {
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
/* Form element: Input */
|
||||
.form-input {
|
||||
appearance: none;
|
||||
background: var(--input-bg-color);
|
||||
background-image: none;
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
color: var(--input-text-color);
|
||||
display: block;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
position: relative;
|
||||
transition: background 0.2s, border 0.2s, color 0.2s;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Input sizes */
|
||||
|
||||
&.input-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.input-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
&.input-inline {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Input types */
|
||||
|
||||
&[type="file"] {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Textarea */
|
||||
textarea.form-input {
|
||||
&,
|
||||
&.input-lg,
|
||||
&.input-sm {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Input hint */
|
||||
.form-input-hint {
|
||||
color: var(--input-hint-color);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--unit-1);
|
||||
|
||||
.has-success &,
|
||||
.is-success + & {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.has-error &,
|
||||
.is-error + & {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Select */
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background: var(--input-bg-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
color: var(--input-text-color);
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
/* Select sizes */
|
||||
|
||||
&.select-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) calc(var(--control-icon-size) + var(--control-padding-x-sm)) var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.select-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) calc(var(--control-icon-size) + var(--control-padding-x-lg)) var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
/* Multiple select */
|
||||
|
||||
&[size],
|
||||
&[multiple] {
|
||||
height: auto;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
|
||||
& option {
|
||||
padding: var(--unit-h) var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
&:not([multiple]):not([size]) {
|
||||
background: var(--input-bg-color) url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center / .4rem .5rem;
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Checkbox and Radio */
|
||||
.form-checkbox,
|
||||
.form-radio,
|
||||
.form-switch {
|
||||
display: block;
|
||||
line-height: var(--line-height);
|
||||
margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;
|
||||
min-height: var(--control-size-sm);
|
||||
padding: calc((var(--control-size-sm) - var(--line-height)) / 2) var(--control-padding-x) calc((var(--control-size-sm) - var(--line-height)) / 2) calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
&:focus-visible + .form-icon {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
&:checked + .form-icon {
|
||||
background: var(--checkbox-checked-bg-color);
|
||||
border-color: var(--checkbox-checked-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
border: var(--border-width) solid var(--checkbox-border-color);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition: background .2s, border .2s, color .2s;
|
||||
}
|
||||
|
||||
/* Input checkbox, radio, and switch sizes */
|
||||
|
||||
&.input-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.input-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
.form-icon {
|
||||
background: var(--checkbox-bg-color);
|
||||
height: var(--control-icon-size);
|
||||
left: 0;
|
||||
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||
width: var(--control-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
font-weight: 500;
|
||||
|
||||
.form-icon {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
background-clip: padding-box;
|
||||
border: var(--border-width-lg) solid var(--checkbox-icon-color);
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
content: "";
|
||||
height: 9px;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
margin-top: -6px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: rotate(45deg);
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&:indeterminate + .form-icon {
|
||||
background: var(--checkbox-checked-bg-color);
|
||||
border-color: var(--checkbox-checked-bg-color);
|
||||
|
||||
&::before {
|
||||
background: var(--checkbox-icon-color);
|
||||
content: "";
|
||||
height: 2px;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
margin-top: -1px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
.form-icon {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
background: var(--checkbox-icon-color);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
height: 6px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Switch */
|
||||
.form-switch {
|
||||
padding-left: calc(var(--unit-8) + var(--control-padding-x));
|
||||
|
||||
.form-icon {
|
||||
background: var(--switch-bg-color);
|
||||
background-clip: padding-box;
|
||||
border-color: var(--switch-border-color);
|
||||
border-radius: calc(var(--unit-2) + var(--border-width));
|
||||
height: calc(var(--unit-4) + var(--border-width) * 2);
|
||||
left: 0;
|
||||
top: calc((var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width));
|
||||
width: var(--unit-8);
|
||||
|
||||
&::before {
|
||||
background: var(--switch-toggle-color);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
display: block;
|
||||
height: var(--unit-4);
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: background .2s, border .2s, color .2s, left .2s;
|
||||
width: var(--unit-4);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:checked + .form-icon {
|
||||
&::before {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Icons */
|
||||
.has-icon-left,
|
||||
.has-icon-right {
|
||||
position: relative;
|
||||
|
||||
.form-icon {
|
||||
height: var(--control-icon-size);
|
||||
margin: 0 var(--control-padding-y);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--control-icon-size);
|
||||
z-index: calc(var(--zindex-0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-left {
|
||||
& .form-icon {
|
||||
left: var(--border-width);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right {
|
||||
& .form-icon {
|
||||
right: var(--border-width);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-y) * 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Form element: Input groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
|
||||
.input-group-addon {
|
||||
background: var(--body-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: var(--line-height);
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
white-space: nowrap;
|
||||
|
||||
&.addon-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.addon-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
flex: 1 1 auto;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.input-group-btn {
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.input-group-addon,
|
||||
.input-group-btn {
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:focus {
|
||||
z-index: calc(var(--zindex-0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&.input-inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form validation states */
|
||||
.form-input,
|
||||
.form-select {
|
||||
.has-success &,
|
||||
&.is-success {
|
||||
background: var(--success-color-shade);
|
||||
border-color: var(--success-color);
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--success-color);
|
||||
}
|
||||
}
|
||||
|
||||
.has-error &,
|
||||
&.is-error {
|
||||
background: var(--error-color-shade);
|
||||
border-color: var(--error-color);
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form disabled and readonly */
|
||||
.form-input,
|
||||
.form-select {
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
background-color: var(--input-disabled-bg-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
& + .form-icon {
|
||||
background: var(--checkbox-disabled-bg-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Increase input font size on small viewports to prevent zooming on focus the input */
|
||||
/* on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max */
|
||||
/* viewport size */
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
89
bookmarks/styles/theme/menus.css
Normal file
89
bookmarks/styles/theme/menus.css
Normal file
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
--menu-bg-color: var(--body-color);
|
||||
--menu-border-color: var(--gray-200);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: var(--box-shadow);
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--primary-text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--primary-color-shade);
|
||||
}
|
||||
|
||||
/* Menus */
|
||||
.menu {
|
||||
background: var(--menu-bg-color);
|
||||
border: solid 1px var(--menu-border-color);
|
||||
border-radius: var(--menu-border-radius);
|
||||
box-shadow: var(--menu-box-shadow);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
min-width: var(--control-width-xs);
|
||||
transform: translateY(var(--layout-spacing-sm));
|
||||
z-index: var(--zindex-3);
|
||||
|
||||
&.menu-nav {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin-top: 0;
|
||||
padding: 0 var(--unit-4);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: var(--unit-2);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
padding-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
& > a, .btn.btn-link {
|
||||
border-radius: var(--menu-border-radius);
|
||||
color: var(--menu-item-color);
|
||||
background: var(--menu-item-bg-color);
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--unit-2));
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio,
|
||||
.form-switch {
|
||||
margin: var(--unit-h) 0;
|
||||
}
|
||||
|
||||
& + .menu-item {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
& .menu-badge {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
.label {
|
||||
margin-right: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
& .divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-2) 0;
|
||||
}
|
||||
}
|
93
bookmarks/styles/theme/modals.css
Normal file
93
bookmarks/styles/theme/modals.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* Modals */
|
||||
:root {
|
||||
--modal-overlay-bg-color: rgba(243, 244, 246, 0.6);
|
||||
--modal-container-bg-color: var(--body-color);
|
||||
--modal-container-border-color: var(--gray-200);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: var(--box-shadow-lg);
|
||||
}
|
||||
|
||||
.modal {
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--layout-spacing);
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
&:target,
|
||||
&.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
z-index: var(--zindex-4);
|
||||
|
||||
& .modal-overlay {
|
||||
animation: fade-in .15s ease 1;
|
||||
background: var(--modal-overlay-bg-color);
|
||||
bottom: 0;
|
||||
cursor: default;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
& .modal-container {
|
||||
animation: fade-in .15s ease 1;
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-overlay, & .modal-container {
|
||||
animation: fade-out .15s ease 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--modal-container-bg-color);
|
||||
border: solid 1px var(--modal-container-border-color);
|
||||
border-radius: var(--modal-border-radius);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
color: var(--text-color);
|
||||
|
||||
& button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: .85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
61
bookmarks/styles/theme/pagination.css
Normal file
61
bookmarks/styles/theme/pagination.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0;
|
||||
padding: var(--unit-1) 0;
|
||||
|
||||
& .page-item {
|
||||
margin: var(--unit-1) var(--unit-o);
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
padding: var(--unit-1) var(--unit-1);
|
||||
}
|
||||
|
||||
& a {
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
& a {
|
||||
cursor: default;
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.page-prev,
|
||||
&.page-next {
|
||||
flex: 1 0 50%;
|
||||
}
|
||||
|
||||
&.page-next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .page-item-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .page-item-subtitle {
|
||||
margin: 0;
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
}
|
26
bookmarks/styles/theme/tables.css
Normal file
26
bookmarks/styles/theme/tables.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/* Tables */
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
/* Scrollable tables */
|
||||
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
}
|
||||
}
|
75
bookmarks/styles/theme/tabs.css
Normal file
75
bookmarks/styles/theme/tabs.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Tabs */
|
||||
:root {
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab {
|
||||
align-items: center;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
|
||||
|
||||
& .tab-item {
|
||||
margin-top: 0;
|
||||
|
||||
& a {
|
||||
border-bottom: var(--border-width-lg) solid transparent;
|
||||
color: var(--tab-color);
|
||||
display: block;
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: var(--unit-2) var(--unit-1) calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--tab-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active a,
|
||||
& a.active {
|
||||
border-bottom-color: var(--tab-highlight-color);
|
||||
color: var(--tab-active-color);
|
||||
}
|
||||
|
||||
&.tab-action {
|
||||
flex: 1 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .btn-clear {
|
||||
margin-top: calc(-1 * var(--unit-1));
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-block {
|
||||
& .tab-item {
|
||||
flex: 1 0 0;
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .badge {
|
||||
&[data-badge]::after {
|
||||
position: absolute;
|
||||
right: var(--unit-h);
|
||||
top: var(--unit-h);
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.tab-block) {
|
||||
& .badge {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
35
bookmarks/styles/theme/toasts.css
Normal file
35
bookmarks/styles/theme/toasts.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* Toasts */
|
||||
.toast {
|
||||
background: var(--gray-600);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--contrast-text-color);
|
||||
display: block;
|
||||
padding: var(--layout-spacing);
|
||||
width: 100%;
|
||||
|
||||
&.toast-primary {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
&.toast-success {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
&.toast-warning {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
&.toast-error {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
margin: var(--unit-h);
|
||||
}
|
||||
|
||||
p {
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
117
bookmarks/styles/theme/typography.css
Normal file
117
bookmarks/styles/theme/typography.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* Typography */
|
||||
/* Headings */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5em;
|
||||
margin-top: 0;
|
||||
}
|
||||
.h1,
|
||||
.h2,
|
||||
.h3,
|
||||
.h4,
|
||||
.h5,
|
||||
.h6 {
|
||||
font-weight: 500;
|
||||
}
|
||||
h1,
|
||||
.h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
h2,
|
||||
.h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
h3,
|
||||
.h3 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
h4,
|
||||
.h4 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h5,
|
||||
.h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6,
|
||||
.h6 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
margin: 0 0 var(--line-height);
|
||||
}
|
||||
|
||||
/* Semantic text elements */
|
||||
a,
|
||||
ins,
|
||||
u {
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: var(--border-width) dotted;
|
||||
cursor: help;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
blockquote {
|
||||
border-left: var(--border-width-lg) solid var(--border-color);
|
||||
margin-left: 0;
|
||||
padding: var(--unit-2) var(--unit-4);
|
||||
|
||||
& p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul,
|
||||
ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
padding: 0;
|
||||
|
||||
& ul,
|
||||
& ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
}
|
||||
|
||||
& li {
|
||||
margin-top: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc inside;
|
||||
|
||||
& ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal inside;
|
||||
|
||||
& ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
& dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin: var(--unit-1) 0 var(--unit-4) 0;
|
||||
}
|
||||
}
|
296
bookmarks/styles/theme/utilities.css
Normal file
296
bookmarks/styles/theme/utilities.css
Normal file
@@ -0,0 +1,296 @@
|
||||
/* Colors */
|
||||
.text-primary {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.icon-color {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
/* Display */
|
||||
.d-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d-inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.d-inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.d-flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.d-inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.d-none,
|
||||
.d-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.d-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.d-invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.text-hide {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.text-assistive {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
color: transparent !important;
|
||||
min-height: var(--unit-4);
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
animation: loading 500ms infinite linear;
|
||||
background: transparent;
|
||||
border: var(--border-width-lg) solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
content: "";
|
||||
display: block;
|
||||
height: var(--unit-4);
|
||||
left: 50%;
|
||||
margin-left: calc(-1 * var(--unit-2));
|
||||
margin-top: calc(-1 * var(--unit-2));
|
||||
opacity: 1;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: var(--unit-4);
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
|
||||
&.loading-lg {
|
||||
min-height: var(--unit-10);
|
||||
|
||||
&::after {
|
||||
height: var(--unit-8);
|
||||
margin-left: calc(-1 * var(--unit-4));
|
||||
margin-top: calc(-1 * var(--unit-4));
|
||||
width: var(--unit-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Position */
|
||||
.m-0 {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ml-0 {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.mr-0 {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.mt-0 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mx-0 {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.my-0 {
|
||||
margin-bottom: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.m-1 {
|
||||
margin: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.mx-1 {
|
||||
margin-left: var(--unit-1) !important;
|
||||
margin-right: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.my-1 {
|
||||
margin-bottom: var(--unit-1) !important;
|
||||
margin-top: var(--unit-1) !important;
|
||||
}
|
||||
|
||||
.m-2 {
|
||||
margin: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.mx-2 {
|
||||
margin-left: var(--unit-2) !important;
|
||||
margin-right: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.my-2 {
|
||||
margin-bottom: var(--unit-2) !important;
|
||||
margin-top: var(--unit-2) !important;
|
||||
}
|
||||
|
||||
.m-4 {
|
||||
margin: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mr-4 {
|
||||
margin-right: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mx-4 {
|
||||
margin-left: var(--unit-4) !important;
|
||||
margin-right: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.my-4 {
|
||||
margin-bottom: var(--unit-4) !important;
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Text */
|
||||
.text-normal {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-large {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
.text-tiny {
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Flex */
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
135
bookmarks/styles/theme/variables.css
Normal file
135
bookmarks/styles/theme/variables.css
Normal file
@@ -0,0 +1,135 @@
|
||||
:root {
|
||||
/* Color palette */
|
||||
--gray-50: rgb(249, 250, 251);
|
||||
--gray-100: rgb(243, 244, 246);
|
||||
--gray-200: rgb(229, 231, 235);
|
||||
--gray-300: rgb(209, 213, 219);
|
||||
--gray-400: rgb(156, 163, 175);
|
||||
--gray-500: rgb(107, 114, 128);
|
||||
--gray-600: rgb(75, 85, 99);
|
||||
--gray-700: rgb(55, 65, 81);
|
||||
--gray-800: rgb(31, 41, 55);
|
||||
--gray-900: rgb(17, 24, 39);
|
||||
|
||||
--primary-color: hsl(241, 63%, 59%);
|
||||
--primary-color-highlight: hsl(241, 63%, 64%);
|
||||
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
|
||||
|
||||
--alternative-color: hsl(179, 94%, 29%);
|
||||
--alternative-color-dark: hsl(179, 94%, 22%);
|
||||
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
|
||||
--error-color: hsl(0, 72%, 51%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-700);
|
||||
--secondary-text-color: var(--gray-500);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 63%, 55%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
|
||||
|
||||
--icon-color: var(--gray-500);
|
||||
|
||||
--border-color: var(--gray-300);
|
||||
--secondary-border-color: var(--gray-200);
|
||||
|
||||
--body-color: #fff;
|
||||
--body-color-contrast: var(--gray-100);
|
||||
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC", "Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
|
||||
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||
|
||||
/* Unit sizes */
|
||||
--unit-o: 0.05rem;
|
||||
--unit-h: 0.1rem;
|
||||
--unit-1: 0.2rem;
|
||||
--unit-2: 0.4rem;
|
||||
--unit-3: 0.6rem;
|
||||
--unit-4: 0.8rem;
|
||||
--unit-5: 1rem;
|
||||
--unit-6: 1.2rem;
|
||||
--unit-7: 1.4rem;
|
||||
--unit-8: 1.6rem;
|
||||
--unit-9: 1.8rem;
|
||||
--unit-10: 2rem;
|
||||
--unit-12: 2.4rem;
|
||||
--unit-16: 3.2rem;
|
||||
|
||||
/* Font sizes */
|
||||
--html-font-size: 20px;
|
||||
--html-line-height: 1.5;
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
--line-height: 1rem;
|
||||
|
||||
/* Sizes */
|
||||
--layout-spacing: var(--unit-2);
|
||||
--layout-spacing-sm: var(--unit-1);
|
||||
--layout-spacing-lg: var(--unit-4);
|
||||
--border-radius: var(--unit-1);
|
||||
--border-radius-lg: var(--unit-2);
|
||||
--border-width: var(--unit-o);
|
||||
--border-width-lg: var(--unit-h);
|
||||
--control-size: var(--unit-8);
|
||||
--control-size-sm: var(--unit-6);
|
||||
--control-size-lg: var(--unit-9);
|
||||
--control-padding-x: var(--unit-2);
|
||||
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
|
||||
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
|
||||
--control-padding-y: calc((var(--control-size) - var(--line-height)) / 2 - var(--border-width));
|
||||
--control-padding-y-sm: calc((var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width));
|
||||
--control-padding-y-lg: calc((var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width));
|
||||
--control-icon-size: 0.8rem;
|
||||
|
||||
--control-width-xs: 180px;
|
||||
--control-width-sm: 320px;
|
||||
--control-width-md: 640px;
|
||||
--control-width-lg: 960px;
|
||||
--control-width-xl: 1280px;
|
||||
|
||||
/* Responsive breakpoints */
|
||||
--size-xs: 480px;
|
||||
--size-sm: 600px;
|
||||
--size-md: 840px;
|
||||
--size-lg: 960px;
|
||||
--size-xl: 1280px;
|
||||
--size-2x: 1440px;
|
||||
|
||||
--responsive-breakpoint: var(--size-xs);
|
||||
|
||||
/* Z-index */
|
||||
--zindex-0: 1;
|
||||
--zindex-1: 100;
|
||||
--zindex-2: 200;
|
||||
--zindex-3: 300;
|
||||
--zindex-4: 400;
|
||||
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid var(--primary-color);
|
||||
--focus-outline-offset: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
$body-bg: #161822 !default;
|
||||
$bg-color: lighten($body-bg, 5%) !default;
|
||||
$bg-color-light: lighten($body-bg, 5%) !default;
|
||||
|
||||
$border-color: #4C4E53 !default;
|
||||
$border-color-dark: $border-color !default;
|
||||
|
||||
$body-font-color: #b5bec8 !default;
|
||||
$light-color: #fafafa !default;
|
||||
|
||||
$gray-color: #7f879b !default;
|
||||
$gray-color-dark: lighten($gray-color, 20%) !default;
|
||||
|
||||
$primary-color: #a8b1ff !default;
|
||||
$primary-color-dark: saturate($primary-color, 5%) !default;
|
||||
$secondary-color: lighten($body-bg, 10%) !default;
|
||||
|
||||
$link-color: $primary-color !default;
|
||||
$link-color-dark: darken($link-color, 5%) !default;
|
||||
$link-color-light: $link-color !default;
|
||||
|
||||
$secondary-link-color: rgba(168, 177, 255, 0.73);
|
||||
|
||||
$alternative-color: #59bdb9;
|
||||
$alternative-color-dark: #73f1eb;
|
||||
|
||||
$code-bg-color: rgba(255, 255, 255, 0.1);
|
||||
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
/* Dark theme specific */
|
||||
$dt-primary-input-color: #5C68E7 !default;
|
||||
$dt-primary-button-color: #5761cb !default;
|
@@ -1,7 +0,0 @@
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
||||
|
||||
$secondary-link-color: rgba(87, 85, 217, 0.64);
|
||||
|
||||
$code-bg-color: rgba(0, 0, 0, 0.05);
|
||||
$code-shadow-color: rgba(0, 0, 0, 0.15);
|
@@ -10,142 +10,144 @@
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
data-turbo-prefetch="false"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">
|
||||
{{ bookmark_item.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="markdown">
|
||||
{% markdown bookmark_item.notes %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{% load shared %}
|
||||
{% htmlmin %}
|
||||
<div class="bulk-edit-bar">
|
||||
<div class="bulk-edit-actions bg-gray">
|
||||
<div class="bulk-edit-actions">
|
||||
<label class="form-checkbox bulk-edit-checkbox all">
|
||||
<input type="checkbox">
|
||||
<i class="form-icon"></i>
|
||||
@@ -27,7 +27,9 @@
|
||||
<input ld-tag-autocomplete variant="small"
|
||||
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
|
||||
</div>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">
|
||||
<span>Execute</span>
|
||||
</button>
|
||||
|
||||
<label class="form-checkbox select-across d-none">
|
||||
<input type="checkbox" name="bulk_select_across">
|
||||
|
@@ -1,7 +1,9 @@
|
||||
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
||||
height="20px">
|
||||
<path
|
||||
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||
<path d="M16 5l3 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<div class="actions">
|
||||
<div class="left-actions">
|
||||
<a class="btn"
|
||||
<a class="btn btn-wide"
|
||||
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
@@ -8,7 +8,7 @@
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
|
||||
class="btn btn-link text-error">
|
||||
class="btn btn-error btn-wide">
|
||||
Delete...
|
||||
</button>
|
||||
</form>
|
||||
|
@@ -36,11 +36,11 @@
|
||||
|
||||
{% if details.is_editable %}
|
||||
<div class="assets-actions">
|
||||
<button type="submit" name="create_snapshot" class="btn btn-link"
|
||||
<button type="submit" name="create_snapshot" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
|
||||
class="btn btn-link">Upload file
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</div>
|
||||
|
@@ -23,8 +23,8 @@
|
||||
<span>Reader mode</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if details.bookmark.web_archive_snapshot_url %}
|
||||
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
|
||||
{% if details.web_archive_snapshot_url %}
|
||||
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -37,6 +37,11 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<div class="status col-2">
|
||||
|
@@ -2,13 +2,15 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
class="width-50 width-md-100" novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -23,10 +23,10 @@
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||
exist it will be
|
||||
automatically created.
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
</div>
|
||||
<div class="form-input-hint auto-tags"></div>
|
||||
{{ form.tag_string.errors }}
|
||||
</div>
|
||||
<div class="form-group has-icon-right">
|
||||
@@ -34,14 +34,15 @@
|
||||
<div class="has-icon-right">
|
||||
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
<i class="form-icon loading"></i>
|
||||
<a class="btn btn-link form-icon" title="Edit title from website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"/>
|
||||
<button type="button" class="btn btn-link form-icon" title="Edit title from website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||
<path d="M16 5l3 3"/>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-input-hint">
|
||||
Optional, leave empty to use title from website.
|
||||
@@ -53,14 +54,15 @@
|
||||
<div class="has-icon-right">
|
||||
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
|
||||
<i class="form-icon loading"></i>
|
||||
<a class="btn btn-link form-icon" title="Edit description from website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"/>
|
||||
<button type="button" class="btn btn-link form-icon" title="Edit description from website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||
<path d="M16 5l3 3"/>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-input-hint">
|
||||
Optional, leave empty to use description from website.
|
||||
@@ -74,11 +76,11 @@
|
||||
</summary>
|
||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
{{ form.notes.errors }}
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
{{ form.notes.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||
@@ -106,12 +108,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<br/>
|
||||
<div class="form-group">
|
||||
<div class="divider"></div>
|
||||
<div class="form-group d-flex justify-between">
|
||||
{% if auto_close %}
|
||||
<input type="submit" value="Save and close" class="btn btn-primary mr-2">
|
||||
<input type="submit" value="Save and close" class="btn btn-primary btn-wide">
|
||||
{% else %}
|
||||
<input type="submit" value="Save" class="btn btn-primary mr-2">
|
||||
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||
</div>
|
||||
@@ -197,6 +199,18 @@
|
||||
} else {
|
||||
bookmarkExistsHint.style['display'] = 'none';
|
||||
}
|
||||
|
||||
// Preview auto tags
|
||||
const autoTags = data.auto_tags;
|
||||
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
|
||||
|
||||
if (autoTags.length > 0) {
|
||||
autoTags.sort();
|
||||
autoTagsHint.style['display'] = 'block';
|
||||
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
|
||||
} else {
|
||||
autoTagsHint.style['display'] = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
{% load static %}
|
||||
{% load sass_tags %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
{# Use data attributes as storage for access in static scripts #}
|
||||
@@ -17,19 +16,18 @@
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="author" content="Sascha Ißbrücker">
|
||||
<title>linkding</title>
|
||||
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<meta name="theme-color" content="#5856e0">
|
||||
{% elif request.user_profile.theme == 'dark' %}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<meta name="theme-color" content="#161822">
|
||||
{% else %}
|
||||
{# Use auto theme as fallback #}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: dark)"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: light)"/>
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
@@ -37,6 +35,11 @@
|
||||
{% if request.user_profile.custom_css %}
|
||||
<style>{{ request.user_profile.custom_css }}</style>
|
||||
{% endif %}
|
||||
<meta name="turbo-cache-control" content="no-preview">
|
||||
{% if not request.global_settings.enable_link_prefetch %}
|
||||
<meta name="turbo-prefetch" content="false">
|
||||
{% endif %}
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
</head>
|
||||
<body ld-global-shortcuts>
|
||||
|
||||
@@ -114,16 +117,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-between">
|
||||
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
|
||||
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>LINKDING</h1>
|
||||
</a>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% elif has_public_shares %}
|
||||
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
||||
{% else %}
|
||||
{# Otherwise show login link #}
|
||||
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
@@ -131,6 +134,5 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -1,88 +1,83 @@
|
||||
{% load shared %}
|
||||
{% htmlmin %}
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div class="dropdown">
|
||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
|
||||
Bookmarks
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
style="height:1rem;width:1rem;vertical-align: middle;">
|
||||
<path fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</a>
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Bookmarks
|
||||
</button>
|
||||
<ul class="menu">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
|
||||
</li>
|
||||
</ul>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
|
||||
</li>
|
||||
<li style="padding-left: 1rem">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li style="padding-left: 1rem">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li style="padding-left: 1rem">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
|
||||
</li>
|
||||
<li style="padding-left: 1rem">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
<div class="divider"></div>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link menu-link">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endhtmlmin %}
|
||||
|
@@ -2,12 +2,14 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" class="width-50 width-md-100" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
{% load sass_tags %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="reader-mode">
|
||||
@@ -6,16 +5,21 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>Reader view</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<meta name="theme-color" content="#5856e0">
|
||||
{% elif request.user_profile.theme == 'dark' %}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<meta name="theme-color" content="#161822">
|
||||
{% else %}
|
||||
{# Use auto theme as fallback #}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: dark)"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: light)"/>
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
|
@@ -1,10 +1,10 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="search-container">
|
||||
<form id="search" class="input-group" action="" method="get" role="search">
|
||||
<form id="search" action="" method="get" role="search">
|
||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||
value="{{ search.q }}">
|
||||
<input type="submit" value="Search" class="btn input-group-btn">
|
||||
<input type="submit" value="Search" class="d-none">
|
||||
{% for hidden_field in search_form.hidden_fields %}
|
||||
{{ hidden_field }}
|
||||
{% endfor %}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
{# Replace search input with auto-complete component #}
|
||||
<script type="application/javascript">
|
||||
window.addEventListener("load", function () {
|
||||
(function init() {
|
||||
const currentTagsString = '{{ tags_string }}';
|
||||
const currentTags = currentTagsString.split(' ');
|
||||
const uniqueTags = [...new Set(currentTags)]
|
||||
@@ -104,5 +104,5 @@
|
||||
}
|
||||
})
|
||||
input.replaceWith(wrapper.firstElementChild);
|
||||
});
|
||||
})();
|
||||
</script>
|
@@ -7,18 +7,19 @@
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
{# Profile section #}
|
||||
{% if success_message %}
|
||||
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
||||
{% endif %}
|
||||
{% if error_message %}
|
||||
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="content-area">
|
||||
{% if success_message %}
|
||||
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
||||
{% endif %}
|
||||
{% if error_message %}
|
||||
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
<h2>Profile</h2>
|
||||
<p>
|
||||
<a href="{% url 'change_password' %}">Change password</a>
|
||||
</p>
|
||||
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
|
||||
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||
@@ -72,7 +73,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bookmark actions</label>
|
||||
<label class="form-label">Bookmark actions</label>
|
||||
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
|
||||
{{ form.display_view_bookmark_action }}
|
||||
<i class="form-icon"></i> View
|
||||
@@ -110,6 +111,31 @@
|
||||
result will also include bookmarks where a search term matches otherwise.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
|
||||
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
In alphabetical mode, tags will be grouped by the first letter.
|
||||
If disabled, tags will not be grouped.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details {% if form.auto_tagging_rules.value %}open{% endif %}>
|
||||
<summary>
|
||||
<span class="form-label d-inline-block">Auto Tagging</span>
|
||||
</summary>
|
||||
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
|
||||
<div>
|
||||
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
Automatically adds tags to bookmarks based on predefined rules.
|
||||
Each line is a single rule that maps a URL to one or more tags. For example:
|
||||
<pre>youtube.com video
|
||||
reddit.com/r/Music music reddit</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_favicons }}
|
||||
@@ -117,6 +143,7 @@
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||
Enabling this feature automatically downloads all missing favicons.
|
||||
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||
If you don't want to use this service, check the <a
|
||||
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
|
||||
@@ -127,6 +154,16 @@
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_preview_images.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_preview_images }}
|
||||
<i class="form-icon"></i> Enable Preview Images
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads preview images for bookmarked websites and displays them next to each bookmark.
|
||||
Enabling this feature automatically downloads all missing preview images.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
@@ -188,10 +225,12 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details {% if form.custom_css.value %}open{% endif %}>
|
||||
<summary>Custom CSS</summary>
|
||||
<summary>
|
||||
<span class="form-label d-inline-block">Custom CSS</span>
|
||||
</summary>
|
||||
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
||||
<div class="mt-2">
|
||||
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
|
||||
<div>
|
||||
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
@@ -199,11 +238,52 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary btn-wide mt-2">
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Global settings section #}
|
||||
{% if global_settings_form %}
|
||||
<section class="content-area">
|
||||
<h2>Global settings</h2>
|
||||
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
|
||||
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
The page that unauthenticated users are redirected to when accessing the root URL.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
|
||||
profile</label>
|
||||
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
|
||||
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
|
||||
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ global_settings_form.enable_link_prefetch.id_for_label }}" class="form-checkbox">
|
||||
{{ global_settings_form.enable_link_prefetch }}
|
||||
<i class="form-icon"></i> Enable prefetching links on hover
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Prefetches internal links when hovering over them. This can improve the perceived performance when
|
||||
navigating application, but also increases the load on the server as well as bandwidth usage.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary btn-wide mt-2">
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# Import section #}
|
||||
<section class="content-area">
|
||||
<h2>Import</h2>
|
||||
@@ -236,7 +316,7 @@
|
||||
<section class="content-area">
|
||||
<h2>Export</h2>
|
||||
<p>Export all bookmarks in Netscape HTML format.</p>
|
||||
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||
<a class="btn btn-primary" target="_blank" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||
{% if export_error %}
|
||||
<div class="has-error">
|
||||
<p class="form-input-hint">
|
||||
@@ -274,35 +354,37 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
||||
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
||||
(function init() {
|
||||
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
||||
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
||||
|
||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||
function updatePublicSharing() {
|
||||
if (enableSharing.checked) {
|
||||
enablePublicSharing.disabled = false;
|
||||
} else {
|
||||
enablePublicSharing.disabled = true;
|
||||
enablePublicSharing.checked = false;
|
||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||
function updatePublicSharing() {
|
||||
if (enableSharing.checked) {
|
||||
enablePublicSharing.disabled = false;
|
||||
} else {
|
||||
enablePublicSharing.disabled = true;
|
||||
enablePublicSharing.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePublicSharing();
|
||||
enableSharing.addEventListener("change", updatePublicSharing);
|
||||
updatePublicSharing();
|
||||
enableSharing.addEventListener("change", updatePublicSharing);
|
||||
|
||||
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||
function updateBookmarkDescriptionMaxLines() {
|
||||
if (bookmarkDescriptionDisplay.value === "inline") {
|
||||
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
|
||||
} else {
|
||||
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
|
||||
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||
function updateBookmarkDescriptionMaxLines() {
|
||||
if (bookmarkDescriptionDisplay.value === "inline") {
|
||||
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
|
||||
} else {
|
||||
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateBookmarkDescriptionMaxLines();
|
||||
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
|
||||
updateBookmarkDescriptionMaxLines();
|
||||
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
@@ -1,70 +1,88 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
<div class="settings-page">
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
<section class="content-area">
|
||||
<h2>Browser Extension</h2>
|
||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
|
||||
<ul>
|
||||
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
|
||||
</ul>
|
||||
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
||||
<h2>Bookmarklet</h2>
|
||||
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
|
||||
first. Here's how it works:</p>
|
||||
<ul>
|
||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||
<li>Open the website that you want to bookmark</li>
|
||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||
</ul>
|
||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
<section class="content-area">
|
||||
<h2>Browser Extension</h2>
|
||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
|
||||
extension is available in the official extension stores for:</p>
|
||||
<ul>
|
||||
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
|
||||
target="_blank">Chrome</a></li>
|
||||
</ul>
|
||||
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
|
||||
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
||||
<h2>Bookmarklet</h2>
|
||||
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
|
||||
application first. Here's how it works:</p>
|
||||
<ul>
|
||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||
<li>Open the website that you want to bookmark</li>
|
||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||
</ul>
|
||||
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
|
||||
<section class="content-area">
|
||||
<h2>REST API</h2>
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="columns">
|
||||
<div class="column width-50 width-md-100">
|
||||
<input class="form-input" value="{{ api_token }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Please treat this token as you would any other credential.</strong>
|
||||
Any party with access to this token can access and manage all your bookmarks.
|
||||
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
||||
After deleting the token, a new one will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
<section class="content-area">
|
||||
<h2>REST API</h2>
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="width-50 width-md-100">
|
||||
<input class="form-input" value="{{ api_token }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Please treat this token as you would any other credential.</strong>
|
||||
Any party with access to this token can access and manage all your bookmarks.
|
||||
If you think that a token was compromised you can revoke (delete) it in the <a
|
||||
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
||||
After deleting the token, a new one will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="content-area">
|
||||
<h2>RSS Feeds</h2>
|
||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||
<ul style="list-style-position: outside;">
|
||||
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
||||
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
|
||||
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
|
||||
</ul>
|
||||
<p>
|
||||
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
||||
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
|
||||
Any party with access to these URLs can read all your bookmarks.
|
||||
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
|
||||
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area">
|
||||
<h2>RSS Feeds</h2>
|
||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||
<ul style="list-style-position: outside;">
|
||||
<li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||
<li><a target="_blank" href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
||||
<li><a target="_blank" href="{{ shared_feed_url }}">Shared bookmarks</a></li>
|
||||
<li><a target="_blank" href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-secondary">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
All URLs support the following URL parameters:
|
||||
</p>
|
||||
<ul style="list-style-position: outside;">
|
||||
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
|
||||
default, only the latest 100 matching bookmarks are included.
|
||||
</li>
|
||||
<li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
|
||||
the bookmarks view and then copying the parameter from the URL.
|
||||
</li>
|
||||
<li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
|
||||
bookmarks and <code>no</code> for read bookmarks.
|
||||
</li>
|
||||
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
|
||||
shared bookmarks and <code>no</code> for unshared bookmarks.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Please note that these URLs include an authentication token that should be treated like any other
|
||||
credential.</strong>
|
||||
Any party with access to these URLs can read all your bookmarks.
|
||||
If you think that a URL was compromised you can delete the feed token for your user in the <a
|
||||
target="_blank" href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
|
||||
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -10,7 +10,7 @@
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features 
 such as user management and bulk operations.">
|
||||
<li class="tab-item">
|
||||
<a href="{% url 'admin:index' %}" target="_blank">
|
||||
<span>Admin</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
|
||||
|
@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
|
||||
|
||||
return self.user
|
||||
|
||||
def setup_superuser(self):
|
||||
return User.objects.create_superuser(
|
||||
"superuser", "superuser@example.com", "password123"
|
||||
)
|
||||
|
||||
def setup_bookmark(
|
||||
self,
|
||||
is_archived: bool = False,
|
||||
@@ -39,6 +44,7 @@ class BookmarkFactoryMixin:
|
||||
website_description: str = "",
|
||||
web_archive_snapshot_url: str = "",
|
||||
favicon_file: str = "",
|
||||
preview_image_file: str = "",
|
||||
added: datetime = None,
|
||||
):
|
||||
if title is None:
|
||||
@@ -67,6 +73,7 @@ class BookmarkFactoryMixin:
|
||||
shared=shared,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
preview_image_file=preview_image_file,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
@@ -85,6 +92,8 @@ class BookmarkFactoryMixin:
|
||||
shared: bool = False,
|
||||
with_tags: bool = False,
|
||||
with_web_archive_snapshot_url: bool = False,
|
||||
with_favicon_file: bool = False,
|
||||
with_preview_image_file: bool = False,
|
||||
user: User = None,
|
||||
):
|
||||
user = user or self.get_or_create_test_user()
|
||||
@@ -116,6 +125,12 @@ class BookmarkFactoryMixin:
|
||||
web_archive_snapshot_url = ""
|
||||
if with_web_archive_snapshot_url:
|
||||
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
||||
favicon_file = ""
|
||||
if with_favicon_file:
|
||||
favicon_file = f"favicon_{i}.png"
|
||||
preview_image_file = ""
|
||||
if with_preview_image_file:
|
||||
preview_image_file = f"preview_image_{i}.png"
|
||||
bookmark = self.setup_bookmark(
|
||||
url=url,
|
||||
title=title,
|
||||
@@ -124,6 +139,8 @@ class BookmarkFactoryMixin:
|
||||
shared=shared,
|
||||
tags=tags,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
preview_image_file=preview_image_file,
|
||||
user=user,
|
||||
)
|
||||
bookmarks.append(bookmark)
|
||||
|
@@ -1,29 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def assertSharedBookmarksLinkCount(self, response, count):
|
||||
url = reverse("bookmarks:shared")
|
||||
self.assertContains(
|
||||
response,
|
||||
f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
|
||||
count=count,
|
||||
)
|
||||
|
||||
def test_publicly_shared_bookmarks_link(self):
|
||||
# should not render link if no public shares exist
|
||||
user = self.setup_user(enable_sharing=True)
|
||||
self.setup_bookmark(user=user, shared=True)
|
||||
|
||||
response = self.client.get(reverse("login"))
|
||||
self.assertSharedBookmarksLinkCount(response, 0)
|
||||
|
||||
# should render link if public shares exist
|
||||
user.profile.enable_public_sharing = True
|
||||
user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("login"))
|
||||
self.assertSharedBookmarksLinkCount(response, 1)
|
190
bookmarks/tests/test_auto_tagging.py
Normal file
190
bookmarks/tests/test_auto_tagging.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from bookmarks.services import auto_tagging
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class AutoTaggingTestCase(TestCase):
|
||||
def test_auto_tag_by_domain(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"example"})
|
||||
|
||||
def test_auto_tag_by_domain_works_with_port(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com:8080/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"example"})
|
||||
|
||||
def test_auto_tag_by_domain_ignores_case(self):
|
||||
script = """
|
||||
EXAMPLE.com example
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"example"})
|
||||
|
||||
def test_auto_tag_by_domain_should_add_all_tags(self):
|
||||
script = """
|
||||
example.com one two three
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"one", "two", "three"})
|
||||
|
||||
def test_auto_tag_by_domain_work_with_idn_domains(self):
|
||||
script = """
|
||||
रजिस्ट्री.भारत tag1
|
||||
"""
|
||||
url = "https://www.xn--81bg3cc2b2bk5hb.xn--h2brj9c/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"tag1"})
|
||||
|
||||
script = """
|
||||
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
|
||||
"""
|
||||
url = "https://www.रजिस्ट्री.भारत/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"tag1"})
|
||||
|
||||
def test_auto_tag_by_domain_and_path(self):
|
||||
script = """
|
||||
example.com/one one
|
||||
example.com/two two
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/one/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"one"})
|
||||
|
||||
def test_auto_tag_by_domain_and_path_ignores_case(self):
|
||||
script = """
|
||||
example.com/One one
|
||||
"""
|
||||
url = "https://example.com/one/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"one"})
|
||||
|
||||
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
|
||||
script = """
|
||||
example.com/one one
|
||||
example.com/two two
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/one/two"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"one"})
|
||||
|
||||
def test_auto_tag_by_domain_ignores_domain_in_path(self):
|
||||
script = """
|
||||
example.com example
|
||||
"""
|
||||
url = "https://test.com/example.com"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_includes_subdomains(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.example.com test
|
||||
some.example.com some
|
||||
"""
|
||||
url = "https://test.example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"example", "test"})
|
||||
|
||||
def test_auto_tag_by_domain_matches_domain_rtl(self):
|
||||
script = """
|
||||
example.com example
|
||||
"""
|
||||
url = "https://example.com.bad-website.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_schema(self):
|
||||
script = """
|
||||
https://example.com/ https
|
||||
http://example.com/ http
|
||||
"""
|
||||
url = "http://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"https", "http"})
|
||||
|
||||
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
|
||||
script = """
|
||||
example.com
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs(self):
|
||||
script = """
|
||||
example.com/page?a=b tag1 # true, matches a=b
|
||||
example.com/page?a=c&c=d tag2 # true, matches both a=c and c=d
|
||||
example.com/page?c=d&l=p tag3 # false, l=p doesn't exists
|
||||
example.com/page?a=bb tag4 # false bb != b
|
||||
example.com/page?a=b&a=c tag5 # true, matches both a=b and a=c
|
||||
example.com/page?a=B tag6 # true, matches a=b because case insensitive
|
||||
example.com/page?A=b tag7 # true, matches a=b because case insensitive
|
||||
"""
|
||||
url = "https://example.com/page/some?z=x&a=b&v=b&c=d&o=p&a=c"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"tag1", "tag2", "tag5", "tag6", "tag7"})
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
|
||||
script = """
|
||||
example.com/page?a= tag1
|
||||
example.com/page?b= tag2
|
||||
"""
|
||||
url = "https://example.com/page/some?a=value"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"tag1"})
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
|
||||
script = """
|
||||
example.com/page?a=йцу tag1
|
||||
example.com/page?a=%D0%B9%D1%86%D1%83 tag2
|
||||
"""
|
||||
url = "https://example.com/page?a=%D0%B9%D1%86%D1%83"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"tag1", "tag2"})
|
@@ -1,10 +1,10 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connections
|
||||
from django.db.utils import DEFAULT_DB_ALIAS
|
||||
from django.test import TransactionTestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from django.db import connections
|
||||
from django.db.utils import DEFAULT_DB_ALIAS
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
@@ -20,9 +20,12 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
return connections[DEFAULT_DB_ALIAS]
|
||||
|
||||
def test_should_not_increase_number_of_queries_per_bookmark(self):
|
||||
# create global settings
|
||||
GlobalSettings.get()
|
||||
|
||||
# create initial bookmarks
|
||||
num_initial_bookmarks = 10
|
||||
for index in range(num_initial_bookmarks):
|
||||
for _ in range(num_initial_bookmarks):
|
||||
self.setup_bookmark(user=self.user, is_archived=True)
|
||||
|
||||
# capture number of queries
|
||||
@@ -37,7 +40,7 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
|
||||
# add more bookmarks
|
||||
num_additional_bookmarks = 10
|
||||
for index in range(num_additional_bookmarks):
|
||||
for _ in range(num_additional_bookmarks):
|
||||
self.setup_bookmark(user=self.user, is_archived=True)
|
||||
|
||||
# assert num queries doesn't increase
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import datetime
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import formats
|
||||
from django.utils import formats, timezone
|
||||
|
||||
from bookmarks.models import BookmarkAsset, UserProfile
|
||||
from bookmarks.services import bookmarks, tasks
|
||||
@@ -180,7 +181,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# no latest snapshot
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# snapshot is not complete
|
||||
self.setup_asset(
|
||||
@@ -194,7 +195,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
status=BookmarkAsset.STATUS_FAILURE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# not a snapshot
|
||||
self.setup_asset(
|
||||
@@ -203,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# snapshot is complete
|
||||
asset = self.setup_asset(
|
||||
@@ -212,20 +213,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
self.assertEqual(self.count_weblinks(soup), 3)
|
||||
|
||||
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
||||
link = self.find_weblink(soup, reader_mode_url)
|
||||
self.assertIsNotNone(link)
|
||||
|
||||
def test_internet_archive_link(self):
|
||||
# without snapshot url
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||
self.assertIsNone(link)
|
||||
|
||||
# with snapshot url
|
||||
def test_internet_archive_link_with_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||
soup = self.get_details(bookmark)
|
||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||
@@ -264,6 +258,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
image = link.select_one("svg")
|
||||
self.assertIsNotNone(image)
|
||||
|
||||
def test_internet_archive_link_with_fallback_url(self):
|
||||
date_added = timezone.datetime(
|
||||
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(url="https://example.com/", added=date_added)
|
||||
fallback_web_archive_url = (
|
||||
"https://web.archive.org/web/20230811214511/https://example.com/"
|
||||
)
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
link = self.find_weblink(soup, fallback_web_archive_url)
|
||||
self.assertIsNotNone(link)
|
||||
self.assertEqual(link["href"], fallback_web_archive_url)
|
||||
self.assertEqual(link.text.strip(), "Internet Archive")
|
||||
|
||||
def test_weblinks_respect_target_setting(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||
|
||||
@@ -300,6 +309,36 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
)
|
||||
|
||||
def test_preview_image(self):
|
||||
# without image
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# with image
|
||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, no image
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, image present
|
||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNotNone(image)
|
||||
self.assertEqual(image["src"], "/static/example.png")
|
||||
|
||||
def test_status(self):
|
||||
# renders form
|
||||
bookmark = self.setup_bookmark()
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user