Compare commits

...

15 Commits

Author SHA1 Message Date
Sascha Ißbrücker
785fe32aaa Bump version 2024-09-14 12:06:32 +02:00
dependabot[bot]
5559ad0070 Bump svelte from 4.2.12 to 4.2.19 (#806)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.12 to 4.2.19.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.19/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.19/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-14 11:43:49 +02:00
Leonhard Markert
76c65566cf Rename "SingeFileError" to "SingleFileError" (#823) 2024-09-14 11:37:03 +02:00
Sascha Ißbrücker
c929e8f11c Speed up navigation (#824)
* use client-side navigation

* update tests

* add setting for enabling link prefetching

* do not prefetch bookmark details

* theme progress bar

* cleanup behaviors

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

* small fixes

* reorganize theme files

* cleanup search bar

* increase spacing

* small tweaks

* fix select styles in Chrome

* cleanup menus

* improve button icons

* restore badges

* remove unused classes

* restore some overrides

* restore bookmark form

* add summary outline

* avoid layout shifts

* restore bookmark details

* increase border radius for modals

* improve details modal

* restore reader mode

* restore settings

* cleanup variables

* start with dark theme

* more dark theme...

* more light theme...

* more dark theme...

* add postcss build

* remove sass processor

* update docker build

* fix alt color

* remove endless symbol

* fix tests

* update assets

* remove sass files

* fix docker build

* cleanup spacing

* improve theme

* update test scripts

* update CI workflow

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

* add tests
2024-08-31 15:39:22 +02:00
Sascha Ißbrücker
36749c398b Update CHANGELOG.md 2024-08-30 19:51:39 +02:00
121 changed files with 6439 additions and 1866 deletions

View File

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

View File

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

2
.gitignore vendored
View File

@@ -183,7 +183,7 @@ typings/
### Custom ### Custom
# Rollup compilation output # Rollup compilation output
/bookmarks/static/bundle.js* /bookmarks/static/bundle.js*
# SASS compilation output # CSS compilation output
/bookmarks/static/theme-*.css* /bookmarks/static/theme-*.css*
# Collected static files for deployment # Collected static files for deployment
/static /static

View File

@@ -1,5 +1,32 @@
# Changelog # Changelog
## v1.31.1 (30/08/2024)
### What's Changed
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
### New Contributors
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
---
## v1.31.0 (16/06/2024) ## v1.31.0 (16/06/2024)
### What's Changed ### What's Changed

BIN
assets/logo-inset.afdesign Normal file

Binary file not shown.

View File

@@ -1,3 +1,5 @@
import logging
from rest_framework import viewsets, mixins, status from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
@@ -19,6 +21,8 @@ from bookmarks.services.bookmarks import (
) )
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
logger = logging.getLogger(__name__)
class BookmarkViewSet( class BookmarkViewSet(
viewsets.GenericViewSet, viewsets.GenericViewSet,
@@ -112,7 +116,13 @@ class BookmarkViewSet(
profile = request.user.profile profile = request.user.profile
auto_tags = [] auto_tags = []
if profile.auto_tagging_rules: if profile.auto_tagging_rules:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url) try:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
exc_info=e,
)
return Response( return Response(
{ {

View File

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

View File

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

View File

@@ -16,9 +16,13 @@ const mutationObserver = new MutationObserver((mutations) => {
}); });
}); });
mutationObserver.observe(document.body, { window.addEventListener("turbo:load", () => {
childList: true, mutationObserver.observe(document.body, {
subtree: true, childList: true,
subtree: true,
});
applyBehaviors(document.body);
}); });
export class Behavior { export class Behavior {

View File

@@ -1,3 +1,4 @@
import "@hotwired/turbo";
import "./behaviors/bookmark-page"; import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit"; import "./behaviors/bulk-edit";
import "./behaviors/confirm-button"; import "./behaviors/confirm-button";

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,7 +84,8 @@ class Bookmark(models.Model):
@property @property
def tag_names(self): def tag_names(self):
return [tag.name for tag in self.tags.all()] names = [tag.name for tag in self.tags.all()]
return sorted(names)
def __str__(self): def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)" return self.resolved_title + " (" + self.url[:30] + "...)"
@@ -169,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
@property @property
def has_notes(self): def has_notes(self):
return self.instance and self.instance.notes return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
class BookmarkSearch: class BookmarkSearch:
@@ -492,3 +495,46 @@ class FeedToken(models.Model):
def __str__(self): def __str__(self):
return self.key return self.key
class GlobalSettings(models.Model):
LANDING_PAGE_LOGIN = "login"
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
LANDING_PAGE_CHOICES = [
(LANDING_PAGE_LOGIN, "Login"),
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
]
landing_page = models.CharField(
max_length=50,
choices=LANDING_PAGE_CHOICES,
blank=False,
default=LANDING_PAGE_LOGIN,
)
guest_profile_user = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
)
enable_link_prefetch = models.BooleanField(default=False, null=False)
@classmethod
def get(cls):
instance = GlobalSettings.objects.first()
if not instance:
instance = GlobalSettings()
instance.save()
return instance
def save(self, *args, **kwargs):
if not self.pk and GlobalSettings.objects.exists():
raise Exception("There is already one instance of GlobalSettings")
return super(GlobalSettings, self).save(*args, **kwargs)
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
def __init__(self, *args, **kwargs):
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

@@ -0,0 +1,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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
.settings-page { .settings-page {
section.content-area { section.content-area {
margin-bottom: $unit-10; margin-bottom: var(--unit-10);
h2 { h2 {
margin-bottom: $unit-3; margin-bottom: var(--unit-3);
} }
} }
@@ -17,6 +17,10 @@
} }
section.about table { section.about table {
max-width: 500px; max-width: 400px;
}
& .form-group {
margin-bottom: var(--unit-4);
} }
} }

View File

@@ -1,204 +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;
}
}
// Hide tooltips on mobile
@media (pointer:coarse) {
.tooltip::after {
display: none;
}
}

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

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

View 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;
}
}
}

View File

@@ -0,0 +1,64 @@
/* Badges */
.badge {
position: relative;
white-space: nowrap;
&[data-badge],
&:not([data-badge]) {
&::after {
background: var(--primary-color);
background-clip: padding-box;
border-radius: .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);
}
}
}

View File

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

View File

@@ -0,0 +1,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;
}
}
}

View File

@@ -0,0 +1,30 @@
/* Code */
:root {
--code-bg-color: var(--body-color-contrast);
--code-color: var(--text-color);
}
code {
border-radius: var(--border-radius);
line-height: 1.25;
padding: .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%;
}
}

View 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);
}
}
}

View File

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

View File

@@ -0,0 +1,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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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);
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}

View 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;
}

View 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);
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -26,7 +26,7 @@
{% if bookmark_list.show_url %} {% if bookmark_list.show_url %}
<div class="url-path truncate"> <div class="url-path truncate">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display"> class="url-display">
{{ bookmark_item.url }} {{ bookmark_item.url }}
</a> </a>
</div> </div>
@@ -58,18 +58,18 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if bookmark_item.notes %} {% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark"> <div class="notes">
<div class="markdown">{% markdown bookmark_item.notes %}</div> <div class="markdown">{% markdown bookmark_item.notes %}</div>
</div> </div>
{% endif %} {% endif %}
<div class="actions text-gray"> <div class="actions">
{% if bookmark_item.display_date %} {% if bookmark_item.display_date %}
{% if bookmark_item.web_archive_snapshot_url %} {% if bookmark_item.web_archive_snapshot_url %}
<a href="{{ bookmark_item.web_archive_snapshot_url }}" <a href="{{ bookmark_item.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}" target="{{ bookmark_list.link_target }}"
rel="noopener"> rel="noopener">
{{ bookmark_item.display_date }} {{ bookmark_item.display_date }}
</a> </a>
{% else %} {% else %}
<span>{{ bookmark_item.display_date }}</span> <span>{{ bookmark_item.display_date }}</span>
@@ -79,8 +79,9 @@
{# View link is visible for both owned and shared bookmarks #} {# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %} {% if bookmark_list.show_view_action %}
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}" <a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
ld-on="click" ld-target="body|append" ld-on="click" ld-target="body|append"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a> data-turbo-prefetch="false"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
{% endif %} {% endif %}
{% if bookmark_item.is_editable %} {% if bookmark_item.is_editable %}
{# Bookmark owner actions #} {# Bookmark owner actions #}

View File

@@ -1,7 +1,7 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray"> <div class="bulk-edit-actions">
<label class="form-checkbox bulk-edit-checkbox all"> <label class="form-checkbox bulk-edit-checkbox all">
<input type="checkbox"> <input type="checkbox">
<i class="form-icon"></i> <i class="form-icon"></i>
@@ -27,7 +27,9 @@
<input ld-tag-autocomplete variant="small" <input ld-tag-autocomplete variant="small"
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..."> name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
</div> </div>
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button> <button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">
<span>Execute</span>
</button>
<label class="form-checkbox select-across d-none"> <label class="form-checkbox select-across d-none">
<input type="checkbox" name="bulk_select_across"> <input type="checkbox" name="bulk_select_across">

View File

@@ -1,7 +1,9 @@
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit"> <button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px" <svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24" fill="none"
height="20px"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/> <path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
<path d="M16 5l3 3"/>
</svg> </svg>
</button> </button>

View File

@@ -1,6 +1,6 @@
<div class="actions"> <div class="actions">
<div class="left-actions"> <div class="left-actions">
<a class="btn" <a class="btn btn-wide"
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a> href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
</div> </div>
<div class="right-actions"> <div class="right-actions">
@@ -8,7 +8,7 @@
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}" <button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
class="btn btn-link text-error"> class="btn btn-error btn-wide">
Delete... Delete...
</button> </button>
</form> </form>

View File

@@ -36,11 +36,11 @@
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-link" <button type="submit" name="create_snapshot" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button> </button>
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button" <button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
class="btn btn-link">Upload file class="btn btn-sm">Upload file
</button> </button>
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide"> <input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div> </div>

View File

@@ -2,13 +2,15 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<section class="content-area"> <div class="bookmarks-form-page">
<div class="content-area-header"> <section class="content-area">
<h2>Edit bookmark</h2> <div class="content-area-header">
</div> <h2>Edit bookmark</h2>
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" </div>
class="width-50 width-md-100" novalidate> <form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
{% bookmark_form form return_url bookmark_id %} novalidate>
</form> {% bookmark_form form return_url bookmark_id %}
</section> </form>
</section>
</div>
{% endblock %} {% endblock %}

View File

@@ -34,14 +34,15 @@
<div class="has-icon-right"> <div class="has-icon-right">
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }} {{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
<i class="form-icon loading"></i> <i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit title from website"> <button type="button" class="btn btn-link form-icon" title="Edit title from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path fill-rule="evenodd" <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" <path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
clip-rule="evenodd"/> <path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
<path d="M16 5l3 3"/>
</svg> </svg>
</a> </button>
</div> </div>
<div class="form-input-hint"> <div class="form-input-hint">
Optional, leave empty to use title from website. Optional, leave empty to use title from website.
@@ -53,14 +54,15 @@
<div class="has-icon-right"> <div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:2" }} {{ form.description|add_class:"form-input"|attr:"rows:2" }}
<i class="form-icon loading"></i> <i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit description from website"> <button type="button" class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="none"
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path fill-rule="evenodd" <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" <path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
clip-rule="evenodd"/> <path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
<path d="M16 5l3 3"/>
</svg> </svg>
</a> </button>
</div> </div>
<div class="form-input-hint"> <div class="form-input-hint">
Optional, leave empty to use description from website. Optional, leave empty to use description from website.
@@ -74,11 +76,11 @@
</summary> </summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label> <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }} {{ form.notes|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
{{ form.notes.errors }}
</details> </details>
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
{{ form.notes.errors }}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox"> <label for="{{ form.unread.id_for_label }}" class="form-checkbox">
@@ -106,12 +108,12 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<br/> <div class="divider"></div>
<div class="form-group"> <div class="form-group d-flex justify-between">
{% if auto_close %} {% if auto_close %}
<input type="submit" value="Save and close" class="btn btn-primary mr-2"> <input type="submit" value="Save and close" class="btn btn-primary btn-wide">
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary mr-2"> <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ cancel_url }}" class="btn">Nevermind</a> <a href="{{ cancel_url }}" class="btn">Nevermind</a>
</div> </div>

View File

@@ -1,5 +1,4 @@
{% load static %} {% load static %}
{% load sass_tags %}
<!DOCTYPE html> <!DOCTYPE html>
{# Use data attributes as storage for access in static scripts #} {# Use data attributes as storage for access in static scripts #}
@@ -17,19 +16,18 @@
<meta name="robots" content="index,follow"> <meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker"> <meta name="author" content="Sascha Ißbrücker">
<title>linkding</title> <title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
{# Include specific theme variant based on user profile setting #} {# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %} {% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> <link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0"> <meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %} {% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> <link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822"> <meta name="theme-color" content="#161822">
{% else %} {% else %}
{# Use auto theme as fallback #} {# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css" <link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/> media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css" <link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/> media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822"> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
@@ -37,6 +35,11 @@
{% if request.user_profile.custom_css %} {% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style> <style>{{ request.user_profile.custom_css }}</style>
{% endif %} {% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head> </head>
<body ld-global-shortcuts> <body ld-global-shortcuts>
@@ -114,16 +117,16 @@
</div> </div>
{% endif %} {% endif %}
<div class="d-flex justify-between"> <div class="d-flex justify-between">
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center"> <a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo"> <img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>LINKDING</h1> <h1>LINKDING</h1>
</a> </a>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #} {# Only show nav items menu when logged in #}
{% include 'bookmarks/nav_menu.html' %} {% include 'bookmarks/nav_menu.html' %}
{% elif has_public_shares %} {% else %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #} {# Otherwise show login link #}
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a> <a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %} {% endif %}
</div> </div>
</header> </header>
@@ -131,6 +134,5 @@
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</body> </body>
</html> </html>

View File

@@ -1,88 +1,83 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
{# Basic menu list #} {# Basic menu list #}
<div class="hide-md"> <div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a> <a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown"> <div class="dropdown">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem"> <button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks Bookmarks
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" </button>
style="height:1rem;width:1rem;vertical-align: middle;"> <ul class="menu">
<path fill-rule="evenodd" <li class="menu-item">
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" <a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
clip-rule="evenodd"/>
</svg>
</a>
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
</li>
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user_profile.enable_sharing %}
<li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li> </li>
{% endif %} <li class="menu-item">
<li> <a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a> </li>
</li> {% if request.user_profile.enable_sharing %}
<li> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a> <a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
</li> </li>
</ul> {% endif %}
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
</li>
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
</li>
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<form class="d-inline" action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-link">Logout</button>
</form>
</div> </div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a> {# Menu drop-down for smaller devices #}
<form class="d-inline" action="{% url 'logout' %}" method="post"> <div class="show-md">
{% csrf_token %} <a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<button type="submit" class="btn btn-link">Logout</button>
</form>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
</a>
<div ld-dropdown class="dropdown dropdown-right">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px"> style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg> </svg>
</a> </a>
<!-- menu component --> <div ld-dropdown class="dropdown dropdown-right">
<ul class="menu"> <button class="btn btn-link dropdown-toggle" tabindex="0">
<li> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a> style="width: 24px; height: 24px">
</li> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
<li style="padding-left: 1rem"> </svg>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a> </button>
</li> <!-- menu component -->
{% if request.user_profile.enable_sharing %} <ul class="menu">
<li style="padding-left: 1rem"> <li class="menu-item">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a> <a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
</li> </li>
{% endif %} <li class="menu-item">
<li style="padding-left: 1rem"> <a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a> </li>
</li> {% if request.user_profile.enable_sharing %}
<li style="padding-left: 1rem"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a> <a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
</li> </li>
<li> {% endif %}
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a> <li class="menu-item">
</li> <a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
<li> </li>
<form class="d-inline" action="{% url 'logout' %}" method="post"> <li class="menu-item">
{% csrf_token %} <a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
<button type="submit" class="btn btn-link">Logout</button> </li>
</form> <div class="divider"></div>
</li> <li class="menu-item">
</ul> <a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
</li>
<li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button>
</form>
</li>
</ul>
</div>
</div> </div>
</div>
{% endhtmlmin %} {% endhtmlmin %}

View File

@@ -2,12 +2,14 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<section class="content-area"> <div class="bookmarks-form-page">
<div class="content-area-header"> <section class="content-area">
<h2>New bookmark</h2> <div class="content-area-header">
</div> <h2>New bookmark</h2>
<form action="{% url 'bookmarks:new' %}" method="post" class="width-50 width-md-100" novalidate> </div>
{% bookmark_form form return_url auto_close=auto_close %} <form action="{% url 'bookmarks:new' %}" method="post" novalidate>
</form> {% bookmark_form form return_url auto_close=auto_close %}
</section> </form>
</section>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,3 @@
{% load sass_tags %}
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="reader-mode"> <html lang="en" class="reader-mode">
@@ -6,16 +5,21 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Reader view</title> <title>Reader view</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %} {% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> <link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %} {% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> <link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %} {% else %}
{# Use auto theme as fallback #} {# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css" <link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/> media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css" <link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/> media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %} {% endif %}
</head> </head>
<body> <body>

View File

@@ -1,10 +1,10 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<div class="search-container"> <div class="search-container">
<form id="search" class="input-group" action="" method="get" role="search"> <form id="search" action="" method="get" role="search">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags" <input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ search.q }}"> value="{{ search.q }}">
<input type="submit" value="Search" class="btn input-group-btn"> <input type="submit" value="Search" class="d-none">
{% for hidden_field in search_form.hidden_fields %} {% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }} {{ hidden_field }}
{% endfor %} {% endfor %}
@@ -77,7 +77,7 @@
{# Replace search input with auto-complete component #} {# Replace search input with auto-complete component #}
<script type="application/javascript"> <script type="application/javascript">
window.addEventListener("load", function () { (function init() {
const currentTagsString = '{{ tags_string }}'; const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' '); const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)] const uniqueTags = [...new Set(currentTags)]
@@ -104,5 +104,5 @@
} }
}) })
input.replaceWith(wrapper.firstElementChild); input.replaceWith(wrapper.firstElementChild);
}); })();
</script> </script>

View File

@@ -7,18 +7,19 @@
{% include 'settings/nav.html' %} {% include 'settings/nav.html' %}
{# Profile section #} {# Profile section #}
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<section class="content-area"> <section class="content-area">
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
</p> </p>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate> <form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label> <label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
@@ -72,7 +73,7 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Bookmark actions</label> <label class="form-label">Bookmark actions</label>
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox"> <label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
{{ form.display_view_bookmark_action }} {{ form.display_view_bookmark_action }}
<i class="form-icon"></i> View <i class="form-icon"></i> View
@@ -120,9 +121,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<details {% if form.auto_tagging_rules.value %}open{% endif %}> <details {% if form.auto_tagging_rules.value %}open{% endif %}>
<summary>Auto Tagging</summary> <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> <label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
<div class="mt-2"> <div>
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }} {{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
</div> </div>
</details> </details>
@@ -222,9 +225,11 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
<div class="form-group"> <div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}> <details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary> <summary>
<span class="form-label d-inline-block">Custom CSS</span>
</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label> <label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div class="mt-2"> <div>
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }} {{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
</div> </div>
</details> </details>
@@ -233,11 +238,52 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2"> <input type="submit" name="update_profile" value="Save" class="btn btn-primary btn-wide mt-2">
</div> </div>
</form> </form>
</section> </section>
{# Global settings section #}
{% if global_settings_form %}
<section class="content-area">
<h2>Global settings</h2>
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
{% csrf_token %}
<div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The page that unauthenticated users are redirected to when accessing the root URL.
</div>
</div>
<div class="form-group">
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
profile</label>
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
</div>
</div>
<div class="form-group">
<label for="{{ global_settings_form.enable_link_prefetch.id_for_label }}" class="form-checkbox">
{{ global_settings_form.enable_link_prefetch }}
<i class="form-icon"></i> Enable prefetching links on hover
</label>
<div class="form-input-hint">
Prefetches internal links when hovering over them. This can improve the perceived performance when
navigating application, but also increases the load on the server as well as bandwidth usage.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary btn-wide mt-2">
</div>
</form>
</section>
{% endif %}
{# Import section #} {# Import section #}
<section class="content-area"> <section class="content-area">
<h2>Import</h2> <h2>Import</h2>
@@ -270,7 +316,7 @@ reddit.com/r/Music music reddit</pre>
<section class="content-area"> <section class="content-area">
<h2>Export</h2> <h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p> <p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a> <a class="btn btn-primary" target="_blank" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %} {% if export_error %}
<div class="has-error"> <div class="has-error">
<p class="form-input-hint"> <p class="form-input-hint">
@@ -308,35 +354,37 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
<script> <script>
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}"); (function init() {
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}"); const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}"); const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}"); const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
// Automatically disable public bookmark sharing if bookmark sharing is disabled // Automatically disable public bookmark sharing if bookmark sharing is disabled
function updatePublicSharing() { function updatePublicSharing() {
if (enableSharing.checked) { if (enableSharing.checked) {
enablePublicSharing.disabled = false; enablePublicSharing.disabled = false;
} else { } else {
enablePublicSharing.disabled = true; enablePublicSharing.disabled = true;
enablePublicSharing.checked = false; enablePublicSharing.checked = false;
}
} }
}
updatePublicSharing(); updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing); enableSharing.addEventListener("change", updatePublicSharing);
// Automatically hide the bookmark description max lines input if the description display is set to inline // Automatically hide the bookmark description max lines input if the description display is set to inline
function updateBookmarkDescriptionMaxLines() { function updateBookmarkDescriptionMaxLines() {
if (bookmarkDescriptionDisplay.value === "inline") { if (bookmarkDescriptionDisplay.value === "inline") {
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide"); bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
} else { } else {
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide"); bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
}
} }
}
updateBookmarkDescriptionMaxLines(); updateBookmarkDescriptionMaxLines();
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines); bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
})();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,70 +1,88 @@
{% extends "bookmarks/layout.html" %} {% extends "bookmarks/layout.html" %}
{% block content %} {% block content %}
<div class="settings-page"> <div class="settings-page">
{% include 'settings/nav.html' %} {% include 'settings/nav.html' %}
<section class="content-area"> <section class="content-area">
<h2>Browser Extension</h2> <h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p> <p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
<ul> extension is available in the official extension stores for:</p>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li> <ul>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li> <li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
</ul> <li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p> target="_blank">Chrome</a></li>
<h2>Bookmarklet</h2> </ul>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application <p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
first. Here's how it works:</p> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<ul> <h2>Bookmarklet</h2>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li> <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
<li>Open the website that you want to bookmark</li> application first. Here's how it works:</p>
<li>Click the bookmarklet in your browsers toolbar</li> <ul>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li> <li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li> <li>Open the website that you want to bookmark</li>
</ul> <li>Click the bookmarklet in your browsers toolbar</li>
<p>Drag the following bookmarklet to your browsers toolbar:</p> <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" <li>After saving the bookmark the linkding window closes and you are back on your website</li>
class="btn btn-primary">📎 Add bookmark</a> </ul>
</section> <p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
<section class="content-area"> <section class="content-area">
<h2>REST API</h2> <h2>REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p> <p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group"> <div class="form-group">
<div class="columns"> <div class="width-50 width-md-100">
<div class="column width-50 width-md-100"> <input class="form-input" value="{{ api_token }}" readonly>
<input class="form-input" value="{{ api_token }}" readonly> </div>
</div> </div>
</div> <p>
</div> <strong>Please treat this token as you would any other credential.</strong>
<p> Any party with access to this token can access and manage all your bookmarks.
<strong>Please treat this token as you would any other credential.</strong> If you think that a token was compromised you can revoke (delete) it in the <a
Any party with access to this token can access and manage all your bookmarks. href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.
After deleting the token, a new one will be generated when you reload this settings page. </p>
</p> </section>
</section>
<section class="content-area"> <section class="content-area">
<h2>RSS Feeds</h2> <h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p> <p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;"> <ul style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li> <li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li> <li><a target="_blank" href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li> <li><a target="_blank" href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li> <li><a target="_blank" href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-secondary">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
</ul> </li>
<p> </ul>
All URLs support appending a <code>q</code> URL parameter for specifying a search query. <p>
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL. All URLs support the following URL parameters:
</p> </p>
<p> <ul style="list-style-position: outside;">
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong> <li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
Any party with access to these URLs can read all your bookmarks. default, only the latest 100 matching bookmarks are included.
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>. </li>
After deleting the feed token, new URLs will be generated when you reload this settings page. <li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
</p> the bookmarks view and then copying the parameter from the URL.
</section> </li>
</div> <li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
bookmarks and <code>no</code> for read bookmarks.
</li>
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
shared bookmarks and <code>no</code> for unshared bookmarks.
</li>
</ul>
<p>
<strong>Please note that these URLs include an authentication token that should be treated like any other
credential.</strong>
Any party with access to these URLs can read all your bookmarks.
If you think that a URL was compromised you can delete the feed token for your user in the <a
target="_blank" href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page.
</p>
</section>
</div>
{% endblock %} {% endblock %}

View File

@@ -10,7 +10,7 @@
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a> <a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
</li> </li>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations."> <li class="tab-item">
<a href="{% url 'admin:index' %}" target="_blank"> <a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span> <span>Admin</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">

View File

@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
return self.user return self.user
def setup_superuser(self):
return User.objects.create_superuser(
"superuser", "superuser@example.com", "password123"
)
def setup_bookmark( def setup_bookmark(
self, self,
is_archived: bool = False, is_archived: bool = False,

View File

@@ -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)

View File

@@ -12,7 +12,18 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"])) 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): def test_auto_tag_by_domain_ignores_case(self):
script = """ script = """
@@ -22,7 +33,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"])) self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_should_add_all_tags(self): def test_auto_tag_by_domain_should_add_all_tags(self):
script = """ script = """
@@ -32,7 +43,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one", "two", "three"])) self.assertEqual(tags, {"one", "two", "three"})
def test_auto_tag_by_domain_work_with_idn_domains(self): def test_auto_tag_by_domain_work_with_idn_domains(self):
script = """ script = """
@@ -42,7 +53,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"])) self.assertEqual(tags, {"tag1"})
script = """ script = """
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1 xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
@@ -51,7 +62,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"])) self.assertEqual(tags, {"tag1"})
def test_auto_tag_by_domain_and_path(self): def test_auto_tag_by_domain_and_path(self):
script = """ script = """
@@ -63,7 +74,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"])) self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_and_path_ignores_case(self): def test_auto_tag_by_domain_and_path_ignores_case(self):
script = """ script = """
@@ -73,7 +84,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"])) self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_and_path_matches_path_ltr(self): def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
script = """ script = """
@@ -85,7 +96,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"])) self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_ignores_domain_in_path(self): def test_auto_tag_by_domain_ignores_domain_in_path(self):
script = """ script = """
@@ -107,7 +118,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example", "test"])) self.assertEqual(tags, {"example", "test"})
def test_auto_tag_by_domain_matches_domain_rtl(self): def test_auto_tag_by_domain_matches_domain_rtl(self):
script = """ script = """
@@ -128,7 +139,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["https", "http"])) self.assertEqual(tags, {"https", "http"})
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self): def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
script = """ script = """
@@ -154,7 +165,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"])) self.assertEqual(tags, {"tag1", "tag2", "tag5", "tag6", "tag7"})
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self): def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
script = """ script = """
@@ -165,7 +176,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"])) self.assertEqual(tags, {"tag1"})
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self): def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
script = """ script = """
@@ -176,4 +187,4 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2"])) self.assertEqual(tags, {"tag1", "tag2"})

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.models import User from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from django.test import TransactionTestCase from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -20,9 +20,12 @@ class BookmarkArchivedViewPerformanceTestCase(
return connections[DEFAULT_DB_ALIAS] return connections[DEFAULT_DB_ALIAS]
def test_should_not_increase_number_of_queries_per_bookmark(self): def test_should_not_increase_number_of_queries_per_bookmark(self):
# create global settings
GlobalSettings.get()
# create initial bookmarks # create initial bookmarks
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
self.setup_bookmark(user=self.user, is_archived=True) self.setup_bookmark(user=self.user, is_archived=True)
# capture number of queries # capture number of queries
@@ -37,7 +40,7 @@ class BookmarkArchivedViewPerformanceTestCase(
# add more bookmarks # add more bookmarks
num_additional_bookmarks = 10 num_additional_bookmarks = 10
for index in range(num_additional_bookmarks): for _ in range(num_additional_bookmarks):
self.setup_bookmark(user=self.user, is_archived=True) self.setup_bookmark(user=self.user, is_archived=True)
# assert num queries doesn't increase # assert num queries doesn't increase

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.models import User from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from django.test import TransactionTestCase from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -18,9 +18,12 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
return connections[DEFAULT_DB_ALIAS] return connections[DEFAULT_DB_ALIAS]
def test_should_not_increase_number_of_queries_per_bookmark(self): def test_should_not_increase_number_of_queries_per_bookmark(self):
# create global settings
GlobalSettings.get()
# create initial bookmarks # create initial bookmarks
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
self.setup_bookmark(user=self.user) self.setup_bookmark(user=self.user)
# capture number of queries # capture number of queries
@@ -35,7 +38,7 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# add more bookmarks # add more bookmarks
num_additional_bookmarks = 10 num_additional_bookmarks = 10
for index in range(num_additional_bookmarks): for _ in range(num_additional_bookmarks):
self.setup_bookmark(user=self.user) self.setup_bookmark(user=self.user)
# assert num queries doesn't increase # assert num queries doesn't increase

View File

@@ -100,6 +100,26 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html, html,
) )
def test_should_prefill_notes_from_url_parameter(self):
response = self.client.get(
reverse("bookmarks:new")
+ "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29"
)
html = response.content.decode()
self.assertInHTML(
"""
<details class="notes" open="">
<summary>
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="id_notes" class="text-assistive">Notes</label>
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
</details>
""",
html,
)
def test_should_enable_auto_close_when_specified_in_url_parameter(self): def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(reverse("bookmarks:new") + "?auto_close") response = self.client.get(reverse("bookmarks:new") + "?auto_close")
html = response.content.decode() html = response.content.decode()

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.models import User from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from django.test import TransactionTestCase from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -18,9 +18,12 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
return connections[DEFAULT_DB_ALIAS] return connections[DEFAULT_DB_ALIAS]
def test_should_not_increase_number_of_queries_per_bookmark(self): def test_should_not_increase_number_of_queries_per_bookmark(self):
# create global settings
GlobalSettings.get()
# create initial users and bookmarks # create initial users and bookmarks
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
user = self.setup_user(enable_sharing=True) user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True) self.setup_bookmark(user=user, shared=True)
@@ -36,7 +39,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# add more users and bookmarks # add more users and bookmarks
num_additional_bookmarks = 10 num_additional_bookmarks = 10
for index in range(num_additional_bookmarks): for _ in range(num_additional_bookmarks):
user = self.setup_user(enable_sharing=True) user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True) self.setup_bookmark(user=user, shared=True)

View File

@@ -5,6 +5,7 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
@@ -16,13 +17,16 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
)[0] )[0]
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key) self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
# create global settings
GlobalSettings.get()
def get_connection(self): def get_connection(self):
return connections[DEFAULT_DB_ALIAS] return connections[DEFAULT_DB_ALIAS]
def test_list_bookmarks_max_queries(self): def test_list_bookmarks_max_queries(self):
# set up some bookmarks with associated tags # set up some bookmarks with associated tags
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
self.setup_bookmark(tags=[self.setup_tag()]) self.setup_bookmark(tags=[self.setup_tag()])
# capture number of queries # capture number of queries
@@ -40,7 +44,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_list_archived_bookmarks_max_queries(self): def test_list_archived_bookmarks_max_queries(self):
# set up some bookmarks with associated tags # set up some bookmarks with associated tags
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]) self.setup_bookmark(is_archived=True, tags=[self.setup_tag()])
# capture number of queries # capture number of queries
@@ -59,7 +63,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
# set up some bookmarks with associated tags # set up some bookmarks with associated tags
share_user = self.setup_user(enable_sharing=True) share_user = self.setup_user(enable_sharing=True)
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
self.setup_bookmark(user=share_user, shared=True, tags=[self.setup_tag()]) self.setup_bookmark(user=share_user, shared=True, tags=[self.setup_tag()])
# capture number of queries # capture number of queries

View File

@@ -9,7 +9,7 @@ from django.test import TestCase, RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils import timezone, formats from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import Bookmark, UserProfile, User from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -44,7 +44,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<a href="{url}" <a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener"> title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content} {label_content}
</a> </a>
<span>|</span> <span>|</span>
""", """,
@@ -74,6 +74,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<a ld-fetch="{details_modal_url}?return_url={return_url}" <a ld-fetch="{details_modal_url}?return_url={return_url}"
ld-on="click" ld-target="body|append" ld-on="click" ld-target="body|append"
data-turbo-prefetch="false"
href="{details_url}">View</a> href="{details_url}">View</a>
""", """,
html, html,
@@ -203,7 +204,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertNotes(self, html: str, notes_html: str, count=1): def assertNotes(self, html: str, notes_html: str, count=1):
self.assertInHTML( self.assertInHTML(
f""" f"""
<div class="notes bg-gray text-gray-dark"> <div class="notes">
<div class="markdown"> <div class="markdown">
{notes_html} {notes_html}
</div> </div>
@@ -270,7 +271,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = user or self.get_or_create_test_user() request.user = user or self.get_or_create_test_user()
middleware = UserProfileMiddleware(lambda r: HttpResponse()) middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request) middleware(request)
bookmark_list_context = context_type(request) bookmark_list_context = context_type(request)
@@ -457,6 +458,22 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
style = bookmark_list["style"] style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:3;", style) self.assertIn("--ld-bookmark-description-max-lines:3;", style)
def test_bookmark_tag_ordering(self):
bookmark = self.setup_bookmark()
tag3 = self.setup_tag(name="tag3")
tag1 = self.setup_tag(name="tag1")
tag2 = self.setup_tag(name="tag2")
bookmark.tags.add(tag3, tag1, tag2)
html = self.render_template()
soup = self.make_soup(html)
tags = soup.select_one(".tags")
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), 3)
self.assertEqual(tag_links[0].text, "#tag1")
self.assertEqual(tag_links[1].text, "#tag2")
self.assertEqual(tag_links[2].text, "#tag3")
def test_should_render_web_archive_link_with_absolute_date_setting(self): def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test( bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE, UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,

View File

@@ -536,7 +536,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_html_snapshot_should_handle_error(self): def test_create_html_snapshot_should_handle_error(self):
bookmark = self.setup_bookmark(url="https://example.com") bookmark = self.setup_bookmark(url="https://example.com")
self.mock_singlefile_create_snapshot.side_effect = singlefile.SingeFileError( self.mock_singlefile_create_snapshot.side_effect = singlefile.SingleFileError(
"Error" "Error"
) )
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)

View File

@@ -23,6 +23,26 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.client.force_login(user) self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0] self.token = FeedToken.objects.get_or_create(user=user)[0]
def assertFeedItems(self, response, bookmarks):
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
categories = []
for tag in bookmark.tag_names:
categories.append(f"<category>{tag}</category>")
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
f"{''.join(categories)}"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_returns_404_for_unknown_feed_token(self): def test_all_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"])) response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"]))
@@ -54,51 +74,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.all", args=[self.token.key]) reverse("bookmarks:feeds.all", args=[self.token.key])
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, bookmarks)
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_all_returns_only_user_owned_bookmarks(self): def test_all_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(
@@ -115,23 +91,6 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertContains(response, "<item>", count=0) self.assertContains(response, "<item>", count=0)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))
def test_unread_returns_404_for_unknown_feed_token(self): def test_unread_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"])) response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"]))
@@ -169,51 +128,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.unread", args=[self.token.key]) reverse("bookmarks:feeds.unread", args=[self.token.key])
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, unread_bookmarks)
self.assertContains(response, "<item>", count=len(unread_bookmarks))
for bookmark in unread_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_unread_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True, tags=[tag1])
bookmark3 = self.setup_bookmark(unread=True, tags=[tag1])
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_unread_returns_only_user_owned_bookmarks(self): def test_unread_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(
@@ -265,53 +180,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.shared", args=[self.token.key]) reverse("bookmarks:feeds.shared", args=[self.token.key])
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, shared_bookmarks)
self.assertContains(response, "<item>", count=len(shared_bookmarks))
for bookmark in shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_shared_with_query(self):
user = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_public_shared_does_not_require_auth(self): def test_public_shared_does_not_require_auth(self):
response = self.client.get(reverse("bookmarks:feeds.public_shared")) response = self.client.get(reverse("bookmarks:feeds.public_shared"))
@@ -351,34 +220,19 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:feeds.public_shared")) response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, public_shared_bookmarks)
self.assertContains(response, "<item>", count=len(public_shared_bookmarks)) def test_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
for bookmark in public_shared_bookmarks: self.setup_bookmark()
expected_item = ( self.setup_bookmark()
"<item>" self.setup_bookmark()
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_public_shared_with_query(self): feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}" url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url) response = self.client.get(url)
@@ -398,3 +252,117 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1) self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1) self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_unread_parameter(self):
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
# without unread parameter
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=6)
# with unread=yes
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=yes"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
# with unread=no
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=no"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=4)
def test_shared_parameter(self):
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
# without shared parameter
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=6)
# with shared=yes
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=yes"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
# with shared=no
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=no"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=4)
def test_with_tags(self):
bookmarks = [
self.setup_bookmark(description="test description"),
self.setup_bookmark(
description="test description",
tags=[self.setup_tag(), self.setup_tag()],
),
]
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, bookmarks)
def test_with_limit(self):
self.setup_numbered_bookmarks(200)
# without limit - defaults to 100
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=100)
# with increased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=200"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=200)
# with decreased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=5"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=5)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))

View File

@@ -4,7 +4,7 @@ from django.test import TestCase
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import FeedToken from bookmarks.models import FeedToken, GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -15,13 +15,16 @@ class FeedsPerformanceTestCase(TestCase, BookmarkFactoryMixin):
self.client.force_login(user) self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0] self.token = FeedToken.objects.get_or_create(user=user)[0]
# create global settings
GlobalSettings.get()
def get_connection(self): def get_connection(self):
return connections[DEFAULT_DB_ALIAS] return connections[DEFAULT_DB_ALIAS]
def test_all_max_queries(self): def test_all_max_queries(self):
# set up some bookmarks with associated tags # set up some bookmarks with associated tags
num_initial_bookmarks = 10 num_initial_bookmarks = 10
for index in range(num_initial_bookmarks): for _ in range(num_initial_bookmarks):
self.setup_bookmark(tags=[self.setup_tag()]) self.setup_bookmark(tags=[self.setup_tag()])
# capture number of queries # capture number of queries

View File

@@ -0,0 +1,65 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
class LayoutTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_nav_menu_should_respect_share_profile_setting(self):
self.user.profile.enable_sharing = False
self.user.profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
self.assertInHTML(
f"""
<a href="{reverse('bookmarks:shared')}" class="menu-link">Shared</a>
""",
html,
count=0,
)
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
self.assertInHTML(
f"""
<a href="{reverse('bookmarks:shared')}" class="menu-link">Shared</a>
""",
html,
count=2,
)
def test_metadata_should_respect_prefetch_links_setting(self):
settings = GlobalSettings.get()
settings.enable_link_prefetch = False
settings.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
self.assertInHTML(
'<meta name="turbo-prefetch" content="false">',
html,
count=1,
)
settings.enable_link_prefetch = True
settings.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
self.assertInHTML(
'<meta name="turbo-prefetch" content="false">',
html,
count=0,
)

View File

@@ -0,0 +1,47 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.middlewares import standard_profile
class LinkdingMiddlewareTestCase(TestCase, BookmarkFactoryMixin):
def test_unauthenticated_user_should_use_standard_profile_by_default(self):
response = self.client.get(reverse("login"))
self.assertEqual(standard_profile, response.wsgi_request.user_profile)
def test_unauthenticated_user_should_use_custom_configured_profile(self):
guest_user = self.setup_user()
guest_user_profile = guest_user.profile
guest_user_profile.theme = UserProfile.THEME_DARK
guest_user_profile.save()
global_settings = GlobalSettings.get()
global_settings.guest_profile_user = guest_user
global_settings.save()
response = self.client.get(reverse("login"))
self.assertEqual(guest_user_profile, response.wsgi_request.user_profile)
def test_authenticated_user_should_use_own_profile(self):
guest_user = self.setup_user()
guest_user_profile = guest_user.profile
guest_user_profile.theme = UserProfile.THEME_DARK
guest_user_profile.save()
global_settings = GlobalSettings.get()
global_settings.guest_profile_user = guest_user
global_settings.save()
user = self.get_or_create_test_user()
user_profile = user.profile
user_profile.theme = UserProfile.THEME_LIGHT
user_profile.save()
self.client.force_login(user)
response = self.client.get(reverse("login"), follow=True)
self.assertEqual(user_profile, response.wsgi_request.user_profile)

View File

@@ -1,38 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class NavMenuTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_respect_share_profile_setting(self):
self.user.profile.enable_sharing = False
self.user.profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
self.assertInHTML(
f"""
<a href="{reverse('bookmarks:shared')}" class="btn btn-link">Shared</a>
""",
html,
count=0,
)
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
self.assertInHTML(
f"""
<a href="{reverse('bookmarks:shared')}" class="btn btn-link">Shared</a>
""",
html,
count=2,
)

View File

@@ -0,0 +1,40 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
class RootViewTestCase(TestCase, BookmarkFactoryMixin):
def test_unauthenticated_user_redirect_to_login_by_default(self):
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("login"))
def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings(
self,
):
settings = GlobalSettings.get()
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:shared"))
def test_authenticated_user_always_redirected_to_bookmarks(self):
self.client.force_login(self.get_or_create_test_user())
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))
settings = GlobalSettings.get()
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))
settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))

View File

@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from requests import RequestException from requests import RequestException
from bookmarks.models import UserProfile from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info from bookmarks.views.settings import app_version, get_version_info
@@ -79,6 +79,13 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("login") + "?next=" + reverse("bookmarks:settings.general"), reverse("login") + "?next=" + reverse("bookmarks:settings.general"),
) )
response = self.client.get(reverse("bookmarks:settings.update"), follow=True)
self.assertRedirects(
response,
reverse("login") + "?next=" + reverse("bookmarks:settings.update"),
)
def test_update_profile(self): def test_update_profile(self):
form_data = { form_data = {
"update_profile": "", "update_profile": "",
@@ -105,7 +112,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"custom_css": "body { background-color: #000; }", "custom_css": "body { background-color: #000; }",
"auto_tagging_rules": "example.com tag", "auto_tagging_rules": "example.com tag",
} }
response = self.client.post(reverse("bookmarks:settings.general"), form_data) response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
)
html = response.content.decode() html = response.content.decode()
self.user.profile.refresh_from_db() self.user.profile.refresh_from_db()
@@ -179,7 +188,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = { form_data = {
"theme": UserProfile.THEME_DARK, "theme": UserProfile.THEME_DARK,
} }
response = self.client.post(reverse("bookmarks:settings.general"), form_data) response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
)
html = response.content.decode() html = response.content.decode()
self.user.profile.refresh_from_db() self.user.profile.refresh_from_db()
@@ -199,14 +210,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_favicons": True, "enable_favicons": True,
} }
) )
self.client.post(reverse("bookmarks:settings.general"), form_data) self.client.post(reverse("bookmarks:settings.update"), form_data)
mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user) mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user)
# No update scheduled if favicons are already enabled # No update scheduled if favicons are already enabled
mock_schedule_bookmarks_without_favicons.reset_mock() mock_schedule_bookmarks_without_favicons.reset_mock()
self.client.post(reverse("bookmarks:settings.general"), form_data) self.client.post(reverse("bookmarks:settings.update"), form_data)
mock_schedule_bookmarks_without_favicons.assert_not_called() mock_schedule_bookmarks_without_favicons.assert_not_called()
@@ -217,7 +228,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
} }
) )
self.client.post(reverse("bookmarks:settings.general"), form_data) self.client.post(reverse("bookmarks:settings.update"), form_data)
mock_schedule_bookmarks_without_favicons.assert_not_called() mock_schedule_bookmarks_without_favicons.assert_not_called()
@@ -229,7 +240,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"refresh_favicons": "", "refresh_favicons": "",
} }
response = self.client.post( response = self.client.post(
reverse("bookmarks:settings.general"), form_data reverse("bookmarks:settings.update"), form_data, follow=True
) )
html = response.content.decode() html = response.content.decode()
@@ -243,9 +254,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
tasks, "schedule_refresh_favicons" tasks, "schedule_refresh_favicons"
) as mock_schedule_refresh_favicons: ) as mock_schedule_refresh_favicons:
form_data = {} form_data = {}
response = self.client.post( response = self.client.post(reverse("bookmarks:settings.update"), form_data)
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode() html = response.content.decode()
mock_schedule_refresh_favicons.assert_not_called() mock_schedule_refresh_favicons.assert_not_called()
@@ -315,14 +324,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_preview_images": True, "enable_preview_images": True,
} }
) )
self.client.post(reverse("bookmarks:settings.general"), form_data) self.client.post(reverse("bookmarks:settings.update"), form_data)
mock_schedule_bookmarks_without_previews.assert_called_once_with(self.user) mock_schedule_bookmarks_without_previews.assert_called_once_with(self.user)
# No update scheduled if favicons are already enabled # No update scheduled if favicons are already enabled
mock_schedule_bookmarks_without_previews.reset_mock() mock_schedule_bookmarks_without_previews.reset_mock()
self.client.post(reverse("bookmarks:settings.general"), form_data) self.client.post(reverse("bookmarks:settings.update"), form_data)
mock_schedule_bookmarks_without_previews.assert_not_called() mock_schedule_bookmarks_without_previews.assert_not_called()
@@ -333,7 +342,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
} }
) )
self.client.post(reverse("bookmarks:settings.general"), form_data) self.client.post(reverse("bookmarks:settings.update"), form_data)
mock_schedule_bookmarks_without_previews.assert_not_called() mock_schedule_bookmarks_without_previews.assert_not_called()
@@ -422,10 +431,11 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"create_missing_html_snapshots": "", "create_missing_html_snapshots": "",
} }
response = self.client.post( response = self.client.post(
reverse("bookmarks:settings.general"), form_data reverse("bookmarks:settings.update"), form_data, follow=True
) )
html = response.content.decode() html = response.content.decode()
self.assertEqual(response.status_code, 200)
mock_create_missing_html_snapshots.assert_called_once() mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage( self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while..." html, "Queued 5 missing snapshots. This may take a while..."
@@ -441,10 +451,11 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"create_missing_html_snapshots": "", "create_missing_html_snapshots": "",
} }
response = self.client.post( response = self.client.post(
reverse("bookmarks:settings.general"), form_data reverse("bookmarks:settings.update"), form_data, follow=True
) )
html = response.content.decode() html = response.content.decode()
self.assertEqual(response.status_code, 200)
mock_create_missing_html_snapshots.assert_called_once() mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(html, "No missing snapshots found.") self.assertSuccessMessage(html, "No missing snapshots found.")
@@ -457,11 +468,97 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
mock_create_missing_html_snapshots.return_value = 5 mock_create_missing_html_snapshots.return_value = 5
form_data = {} form_data = {}
response = self.client.post( response = self.client.post(
reverse("bookmarks:settings.general"), form_data reverse("bookmarks:settings.update"), form_data, follow=True
) )
html = response.content.decode() html = response.content.decode()
self.assertEqual(response.status_code, 200)
mock_create_missing_html_snapshots.assert_not_called() mock_create_missing_html_snapshots.assert_not_called()
self.assertSuccessMessage( self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0 html, "Queued 5 missing snapshots. This may take a while...", count=0
) )
def test_update_global_settings(self):
superuser = self.setup_superuser()
self.client.force_login(superuser)
selectable_user = self.setup_user()
# Update global settings
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
"guest_profile_user": selectable_user.id,
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
global_settings = GlobalSettings.get()
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
self.assertEqual(global_settings.guest_profile_user, selectable_user)
# Revert settings
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_LOGIN,
"guest_profile_user": "",
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
global_settings = GlobalSettings.get()
global_settings.refresh_from_db()
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
self.assertIsNone(global_settings.guest_profile_user)
def test_update_global_settings_should_not_be_called_without_respective_form_action(
self,
):
superuser = self.setup_superuser()
self.client.force_login(superuser)
form_data = {
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(
response.content.decode(), "Global settings updated", count=0
)
def test_update_global_settings_checks_for_superuser(self):
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
}
response = self.client.post(reverse("bookmarks:settings.update"), form_data)
self.assertEqual(response.status_code, 403)
def test_global_settings_only_visible_for_superuser(self):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"<h2>Global settings</h2>",
html,
count=0,
)
superuser = self.setup_superuser()
self.client.force_login(superuser)
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"<h2>Global settings</h2>",
html,
count=1,
)

View File

@@ -68,17 +68,18 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
token = FeedToken.objects.first() token = FeedToken.objects.first()
self.assertInHTML( self.assertInHTML(
f'<a href="http://testserver/feeds/{token.key}/all">All bookmarks</a>', html f'<a target="_blank" href="http://testserver/feeds/{token.key}/all">All bookmarks</a>',
)
self.assertInHTML(
f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
html, html,
) )
self.assertInHTML( self.assertInHTML(
f'<a href="http://testserver/feeds/{token.key}/shared">Shared bookmarks</a>', f'<a target="_blank" href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
html, html,
) )
self.assertInHTML( self.assertInHTML(
f'<a href="http://testserver/feeds/shared">Public shared bookmarks</a>', f'<a target="_blank" href="http://testserver/feeds/{token.key}/shared">Shared bookmarks</a>',
html,
)
self.assertInHTML(
'<a target="_blank" href="http://testserver/feeds/shared">Public shared bookmarks</a>',
html, html,
) )

View File

@@ -43,12 +43,12 @@ class SingleFileServiceTestCase(TestCase):
with mock.patch("subprocess.Popen") as mock_popen: with mock.patch("subprocess.Popen") as mock_popen:
mock_popen.side_effect = subprocess.CalledProcessError(1, "command") mock_popen.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(singlefile.SingeFileError): with self.assertRaises(singlefile.SingleFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath) singlefile.create_snapshot("http://example.com", self.html_filepath)
# so also check that it raises error if output file isn't created # so also check that it raises error if output file isn't created
with mock.patch("subprocess.Popen"): with mock.patch("subprocess.Popen"):
with self.assertRaises(singlefile.SingeFileError): with self.assertRaises(singlefile.SingleFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath) singlefile.create_snapshot("http://example.com", self.html_filepath)
def test_create_snapshot_empty_options(self): def test_create_snapshot_empty_options(self):

View File

@@ -5,7 +5,7 @@ from django.http import HttpResponse
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.middlewares import UserProfileMiddleware from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -21,7 +21,7 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = user or self.get_or_create_test_user() request.user = user or self.get_or_create_test_user()
middleware = UserProfileMiddleware(lambda r: HttpResponse()) middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request) middleware(request)
tag_cloud_context = context_type(request) tag_cloud_context = context_type(request)

View File

@@ -1,6 +1,5 @@
from django.urls import path, include from django.urls import path, include
from django.urls import re_path from django.urls import re_path
from django.views.generic import RedirectView
from bookmarks import views from bookmarks import views
from bookmarks.api.routes import router from bookmarks.api.routes import router
@@ -14,10 +13,8 @@ from bookmarks.views import partials
app_name = "bookmarks" app_name = "bookmarks"
urlpatterns = [ urlpatterns = [
# Redirect root to bookmarks index # Root view handling redirection based on user authentication
re_path( re_path(r"^$", views.root, name="root"),
r"^$", RedirectView.as_view(pattern_name="bookmarks:index", permanent=False)
),
# Bookmarks # Bookmarks
path("bookmarks", views.bookmarks.index, name="index"), path("bookmarks", views.bookmarks.index, name="index"),
path("bookmarks/action", views.bookmarks.index_action, name="index.action"), path("bookmarks/action", views.bookmarks.index_action, name="index.action"),
@@ -109,6 +106,7 @@ urlpatterns = [
# Settings # Settings
path("settings", views.settings.general, name="settings.index"), path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"), path("settings/general", views.settings.general, name="settings.general"),
path("settings/update", views.settings.update, name="settings.update"),
path( path(
"settings/integrations", "settings/integrations",
views.settings.integrations, views.settings.integrations,

View File

@@ -4,3 +4,4 @@ from .settings import *
from .toasts import * from .toasts import *
from .health import health from .health import health
from .manifest import manifest from .manifest import manifest
from .root import root

View File

@@ -189,9 +189,11 @@ def convert_tag_string(tag_string: str):
@login_required @login_required
def new(request): def new(request):
status = 200
initial_url = request.GET.get("url") initial_url = request.GET.get("url")
initial_title = request.GET.get("title") initial_title = request.GET.get("title")
initial_description = request.GET.get("description") initial_description = request.GET.get("description")
initial_notes = request.GET.get("notes")
initial_auto_close = "auto_close" in request.GET initial_auto_close = "auto_close" in request.GET
initial_mark_unread = request.user.profile.default_mark_unread initial_mark_unread = request.user.profile.default_mark_unread
@@ -206,6 +208,8 @@ def new(request):
return HttpResponseRedirect(reverse("bookmarks:close")) return HttpResponseRedirect(reverse("bookmarks:close"))
else: else:
return HttpResponseRedirect(reverse("bookmarks:index")) return HttpResponseRedirect(reverse("bookmarks:index"))
else:
status = 422
else: else:
form = BookmarkForm() form = BookmarkForm()
if initial_url: if initial_url:
@@ -214,6 +218,8 @@ def new(request):
form.initial["title"] = initial_title form.initial["title"] = initial_title
if initial_description: if initial_description:
form.initial["description"] = initial_description form.initial["description"] = initial_description
if initial_notes:
form.initial["notes"] = initial_notes
if initial_auto_close: if initial_auto_close:
form.initial["auto_close"] = "true" form.initial["auto_close"] = "true"
if initial_mark_unread: if initial_mark_unread:
@@ -225,7 +231,7 @@ def new(request):
"return_url": reverse("bookmarks:index"), "return_url": reverse("bookmarks:index"),
} }
return render(request, "bookmarks/new.html", context) return render(request, "bookmarks/new.html", context, status=status)
@login_required @login_required

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