Compare commits
32 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
93e2832a89 | ||
![]() |
f5708594a7 | ||
![]() |
67f237c1de | ||
![]() |
95f489ea48 | ||
![]() |
ed57da3c99 | ||
![]() |
c5c5949d20 | ||
![]() |
f4e66c1ff1 | ||
![]() |
fe7ddbe645 | ||
![]() |
afa57aa10b | ||
![]() |
b4108c9a56 | ||
![]() |
6cf5fb396a | ||
![]() |
3d8866c7bc | ||
![]() |
8544137a31 | ||
![]() |
baa3d5596d | ||
![]() |
f79c24453c | ||
![]() |
f3c1101746 | ||
![]() |
ceceb56164 | ||
![]() |
450980a8d4 | ||
![]() |
2aab2813f4 | ||
![]() |
0e488b7ce3 | ||
![]() |
53e4aeb1c1 | ||
![]() |
2b3cd2dec1 | ||
![]() |
c22e30cbda | ||
![]() |
ffaaf0521d | ||
![]() |
db225d5267 | ||
![]() |
74e65bc366 | ||
![]() |
edba98f1fe | ||
![]() |
785fe32aaa | ||
![]() |
5559ad0070 | ||
![]() |
76c65566cf | ||
![]() |
c929e8f11c | ||
![]() |
3ae9cf0420 |
@@ -2,7 +2,7 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
|
@@ -10,6 +10,7 @@
|
||||
!/manage.py
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.mjs
|
||||
|
7
.github/workflows/main.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -53,7 +53,6 @@ jobs:
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
python manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
|
2
.gitignore
vendored
@@ -183,7 +183,7 @@ typings/
|
||||
### Custom
|
||||
# Rollup compilation output
|
||||
/bookmarks/static/bundle.js*
|
||||
# SASS compilation output
|
||||
# CSS compilation output
|
||||
/bookmarks/static/theme-*.css*
|
||||
# Collected static files for deployment
|
||||
/static
|
||||
|
45
CHANGELOG.md
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## v1.34.0 (16/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825
|
||||
* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829
|
||||
* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826
|
||||
|
||||
### New Contributors
|
||||
* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0
|
||||
|
||||
---
|
||||
|
||||
## v1.33.0 (14/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Theme improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/822
|
||||
* Speed up navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/824
|
||||
* Rename "SingeFileError" to "SingleFileError" by @curiousleo in https://github.com/sissbruecker/linkding/pull/823
|
||||
* Bump svelte from 4.2.12 to 4.2.19 by @dependabot in https://github.com/sissbruecker/linkding/pull/806
|
||||
|
||||
### New Contributors
|
||||
* @curiousleo made their first contribution in https://github.com/sissbruecker/linkding/pull/823
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.32.0...v1.33.0
|
||||
|
||||
---
|
||||
|
||||
## v1.32.0 (10/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Allow configuring landing page for unauthenticated users by @sissbruecker in https://github.com/sissbruecker/linkding/pull/808
|
||||
* Allow configuring guest user profile by @sissbruecker in https://github.com/sissbruecker/linkding/pull/809
|
||||
* Return bookmark tags in RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/810
|
||||
* Additional filter parameters for RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/811
|
||||
* Allow pre-filling notes in new bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/812
|
||||
* Fix inconsistent tag order in bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/819
|
||||
* Fix auto-tagging when URL includes port by @sissbruecker in https://github.com/sissbruecker/linkding/pull/820
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.1...v1.32.0
|
||||
|
||||
---
|
||||
|
||||
## v1.31.1 (30/08/2024)
|
||||
|
||||
### What's Changed
|
||||
|
1
Makefile
@@ -13,3 +13,4 @@ format:
|
||||
black bookmarks
|
||||
black siteroot
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
||||
|
232
README.md
@@ -1,25 +1,11 @@
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://github.com/sissbruecker/linkding">
|
||||
<img src="docs/header.svg" height="50">
|
||||
<img src="assets/header.svg" height="50">
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
- [Introduction](#introduction)
|
||||
- [Installation](#installation)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Docker Compose](#using-docker-compose)
|
||||
- [User Setup](#user-setup)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Managed Hosting Options](#managed-hosting-options)
|
||||
- [Documentation](#documentation)
|
||||
- [Browser Extension](#browser-extension)
|
||||
- [Community](#community)
|
||||
- [Acknowledgements + Donations](#acknowledgements--donations)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
|
||||
linkding is a bookmark manager that you can host yourself.
|
||||
@@ -49,219 +35,33 @@ The name comes from:
|
||||
|
||||
**Screenshot:**
|
||||
|
||||

|
||||

|
||||
|
||||
## Installation
|
||||
## Getting Started
|
||||
|
||||
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
|
||||
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
|
||||
linkding uses an SQLite database by default.
|
||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||
|
||||
### Using Docker
|
||||
|
||||
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>latest</code></td>
|
||||
<td>Provides the basic functionality of linkding</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-plus</code></td>
|
||||
<td>
|
||||
Includes feature for archiving websites as HTML snapshots
|
||||
<ul>
|
||||
<li>Significantly larger image size as it includes a Chromium installation</li>
|
||||
<li>Requires more runtime memory to run Chromium</li>
|
||||
<li>Requires more disk space for storing HTML snapshots</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-alpine</code></td>
|
||||
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-plus-alpine</code></td>
|
||||
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
|
||||
|
||||
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
|
||||
|
||||
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### User Setup
|
||||
|
||||
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
**Docker Compose**
|
||||
```shell
|
||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
|
||||
|
||||
<details>
|
||||
<summary>Apache</summary>
|
||||
|
||||
Apache2 does not change the headers by default, and should not
|
||||
need additional configuration.
|
||||
|
||||
An example virtual host that proxies to linkding might look like:
|
||||
```
|
||||
<VirtualHost *:9100>
|
||||
<Proxy *>
|
||||
Order deny,allow
|
||||
Allow from all
|
||||
</Proxy>
|
||||
|
||||
ProxyPass / http://linkding:9090/
|
||||
ProxyPassReverse / http://linkding:9090/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
|
||||
|
||||
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Caddy 2</summary>
|
||||
|
||||
Caddy does not change the headers by default, and should not need any further configuration.
|
||||
|
||||
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
|
||||
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
|
||||
```
|
||||
location /linkding {
|
||||
...
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
### Managed Hosting Options
|
||||
|
||||
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
|
||||
|
||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
||||
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
||||
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
|
||||
The following links help you to get started with linkding:
|
||||
- [Install linkding on your own server](https://linkding.link/installation) or [check managed hosting options](https://linkding.link/managed-hosting)
|
||||
- [Install the browser extension](https://linkding.link/browser-extension)
|
||||
- [Check out community projects](https://linkding.link/community), which include mobile apps, browser extensions, libraries and more
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
|
||||
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
||||
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
|
||||
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
|
||||
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
|
||||
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
|
||||
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||
The full documentation is now available at [linkding.link](https://linkding.link/).
|
||||
|
||||
## Browser Extension
|
||||
If you want to contribute to the documentation, you can find the source files in the `docs` folder.
|
||||
|
||||
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
|
||||
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
|
||||
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
If you want to contribute a community project, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md).
|
||||
|
||||
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
||||
## Contributing
|
||||
|
||||
## Community
|
||||
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements + Donations
|
||||
|
||||
### PikaPods
|
||||
|
||||
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
|
||||
|
||||
See the table below for a list of donations.
|
||||
|
||||
| Source | Description | Amount | Donated to |
|
||||
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
|
||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
|
||||
|
||||
### JetBrains
|
||||
|
||||
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||
Small improvements, bugfixes and documentation improvements are always welcome. If you want to contribute a larger feature, consider opening an issue first to discuss it. I may choose to ignore PRs for features that don't align with the project's goals or that I don't want to maintain.
|
||||
|
||||
## Development
|
||||
|
||||
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.10
|
||||
- Python 3.12
|
||||
- Node.js
|
||||
|
||||
### Setup
|
||||
@@ -305,7 +105,7 @@ The frontend is now available under http://localhost:8000
|
||||
|
||||
Run all tests with pytest:
|
||||
```
|
||||
pytest
|
||||
make test
|
||||
```
|
||||
|
||||
### Formatting
|
||||
@@ -317,7 +117,7 @@ make format
|
||||
|
||||
### DevContainers
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
||||
|
||||
Once checked out, only the following commands are required to get started:
|
||||
|
||||
|
BIN
assets/header.afdesign
Normal file
BIN
assets/header.png
Normal file
After Width: | Height: | Size: 53 KiB |
@@ -4,12 +4,12 @@
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1265.31,-1395.82)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-1017.49,-1140.55)">
|
||||
<g transform="matrix(0.823127,0,0,0.823127,-786.171,-888.198)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:31.25px;"/>
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:31.25px;"/>
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
assets/logo-inset.afdesign
Normal file
BIN
assets/logo.png
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -1 +1,17 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 450 450" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1,0,0,1,-70.3466,-70.3466)">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1257.39,-1386.74)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.793058,0,0,0.793058,-739.034,-836.215)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 688 B After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -56,7 +56,12 @@ class BookmarkViewSet(
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"request": self.request, "user": self.request.user}
|
||||
disable_scraping = "disable_scraping" in self.request.GET
|
||||
return {
|
||||
"request": self.request,
|
||||
"user": self.request.user,
|
||||
"disable_scraping": disable_scraping,
|
||||
}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
@@ -101,16 +106,7 @@ class BookmarkViewSet(
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
@@ -120,7 +116,7 @@ class BookmarkViewSet(
|
||||
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}",
|
||||
f"Failed to auto-tag bookmark. url={url}",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
|
@@ -4,7 +4,11 @@ from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
from bookmarks.services.bookmarks import (
|
||||
create_bookmark,
|
||||
update_bookmark,
|
||||
enhance_with_website_metadata,
|
||||
)
|
||||
from bookmarks.services.tags import get_or_create_tag
|
||||
|
||||
|
||||
@@ -29,8 +33,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
@@ -40,15 +42,17 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
read_only_fields = [
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
list_serializer_class = BookmarkListSerializer
|
||||
|
||||
@@ -63,6 +67,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
favicon_url = serializers.SerializerMethodField()
|
||||
preview_image_url = serializers.SerializerMethodField()
|
||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||
website_title = serializers.SerializerMethodField()
|
||||
website_description = serializers.SerializerMethodField()
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
@@ -80,6 +87,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def get_website_title(self, obj: Bookmark):
|
||||
return None
|
||||
|
||||
def get_website_description(self, obj: Bookmark):
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
bookmark.url = validated_data["url"]
|
||||
@@ -90,7 +103,14 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
bookmark.unread = validated_data["unread"]
|
||||
bookmark.shared = validated_data["shared"]
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
return create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
|
||||
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
# Unless scraping is explicitly disabled, enhance bookmark with website
|
||||
# metadata to preserve backwards compatibility with clients that expect
|
||||
# title and description to be populated automatically when left empty
|
||||
if not self.context.get("disable_scraping", False):
|
||||
enhance_with_website_metadata(saved_bookmark)
|
||||
return saved_bookmark
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
# Update fields if they were provided in the payload
|
||||
|
@@ -121,8 +121,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with self.page.expect_navigation():
|
||||
details_modal.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
# Cancel edit, verify return to details url
|
||||
details_url = url + f"&details={bookmark.id}"
|
||||
with self.page.expect_navigation(url=self.live_server_url + details_url):
|
||||
self.page.get_by_text("Nevermind").click()
|
||||
|
||||
def test_delete(self):
|
||||
@@ -134,6 +135,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
# Wait for confirm button to be initialized
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
# Delete bookmark, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
details_modal.get_by_text("Delete...").click()
|
||||
@@ -167,7 +171,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
# Has new snapshots
|
||||
expect(snapshot).to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
# Remove snapshot
|
||||
asset_list.get_by_text("Remove", exact=False).click()
|
||||
asset_list.get_by_text("Confirm", exact=False).click()
|
||||
|
||||
|
@@ -1,37 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_edit_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||
|
||||
# Navigate to edit page
|
||||
with self.page.expect_navigation():
|
||||
self.page.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(
|
||||
url=self.live_server_url
|
||||
+ reverse("bookmarks:details", args=[bookmark.id])
|
||||
):
|
||||
self.page.get_by_text("Nevermind").click()
|
||||
|
||||
def test_delete_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||
|
||||
# Trigger delete, verify return url
|
||||
# Should probably return to last bookmark list page, but for now just returns to index
|
||||
with self.page.expect_navigation(
|
||||
url=self.live_server_url + reverse("bookmarks:index")
|
||||
):
|
||||
self.page.get_by_text("Delete...").click()
|
||||
self.page.get_by_text("Confirm").click()
|
@@ -1,109 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(
|
||||
title="Existing title",
|
||||
description="Existing description",
|
||||
notes="Existing notes",
|
||||
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
|
||||
website_title="Existing website title",
|
||||
website_description="Existing website description",
|
||||
unread=True,
|
||||
)
|
||||
tag_names = " ".join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label("URL").fill(existing_bookmark.url)
|
||||
# Already bookmarked hint should be visible
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(
|
||||
existing_bookmark.title, page.get_by_label("Title").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.description,
|
||||
page.get_by_label("Description").input_value(),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.notes, page.get_by_label("Notes").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.website_title,
|
||||
page.get_by_label("Title").get_attribute("placeholder"),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.website_description,
|
||||
page.get_by_label("Description").get_attribute("placeholder"),
|
||||
)
|
||||
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
|
||||
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
|
||||
|
||||
# Enter non-bookmarked URL
|
||||
page.get_by_label("URL").fill("https://example.com/unknown")
|
||||
# Already bookmarked hint should be hidden
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||
state="hidden", timeout=2000
|
||||
)
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_edit_should_not_check_for_existing_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(
|
||||
self.live_server_url + reverse("bookmarks:edit", args=[bookmark.id])
|
||||
)
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
notes="Existing notes", description="Existing description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
details = page.locator("details.notes")
|
||||
expect(details).not_to_have_attribute("open", value="")
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
url = self.live_server_url + reverse("bookmarks:new")
|
||||
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
page.goto(url)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
65
bookmarks/e2e/e2e_test_edit_bookmark_form.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
mock_website_metadata = website_loader.WebsiteMetadata(
|
||||
url="https://example.com",
|
||||
title="Example Domain",
|
||||
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
|
||||
preview_image=None,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.website_loader_patch = patch.object(
|
||||
website_loader, "load_website_metadata", return_value=mock_website_metadata
|
||||
)
|
||||
self.website_loader_patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.website_loader_patch.stop()
|
||||
|
||||
def test_should_not_check_for_existing_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:edit", args=[bookmark.id]), p)
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
|
||||
|
||||
def test_should_not_prefill_title_and_description(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
title="Initial title", description="Initial description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:edit", args=[bookmark.id]), p)
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
expect(title).to_have_value(bookmark.title)
|
||||
expect(description).to_have_value(bookmark.description)
|
||||
|
||||
def test_enter_url_should_not_prefill_title_and_description(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:edit", args=[bookmark.id]), p)
|
||||
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
expect(title).to_have_value(bookmark.title)
|
||||
expect(description).to_have_value(bookmark.description)
|
166
bookmarks/e2e/e2e_test_new_bookmark_form.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
mock_website_metadata = website_loader.WebsiteMetadata(
|
||||
url="https://example.com",
|
||||
title="Example Domain",
|
||||
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
|
||||
preview_image=None,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.website_loader_patch = patch.object(
|
||||
website_loader, "load_website_metadata", return_value=mock_website_metadata
|
||||
)
|
||||
self.website_loader_patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.website_loader_patch.stop()
|
||||
|
||||
def test_enter_url_prefills_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
url.fill("https://example.com")
|
||||
expect(title).to_have_value("Example Domain")
|
||||
expect(description).to_have_value(
|
||||
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
|
||||
)
|
||||
|
||||
def test_enter_url_does_not_overwrite_modified_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
title.fill("Modified title")
|
||||
description.fill("Modified description")
|
||||
url.fill("https://example.com")
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(title).to_have_value("Modified title")
|
||||
expect(description).to_have_value("Modified description")
|
||||
|
||||
def test_with_initial_url_prefills_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page_url = reverse("bookmarks:new") + f"?url={quote('https://example.com')}"
|
||||
page = self.open(page_url, p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(url).to_have_value("https://example.com")
|
||||
expect(title).to_have_value("Example Domain")
|
||||
expect(description).to_have_value(
|
||||
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
|
||||
)
|
||||
|
||||
def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
|
||||
self,
|
||||
):
|
||||
with sync_playwright() as p:
|
||||
page_url = (
|
||||
reverse("bookmarks:new")
|
||||
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
|
||||
)
|
||||
page = self.open(page_url, p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(url).to_have_value("https://example.com")
|
||||
expect(title).to_have_value("Initial title")
|
||||
expect(description).to_have_value("Initial description")
|
||||
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(
|
||||
title="Existing title",
|
||||
description="Existing description",
|
||||
notes="Existing notes",
|
||||
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
|
||||
unread=True,
|
||||
)
|
||||
tag_names = " ".join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label("URL").fill(existing_bookmark.url)
|
||||
# Already bookmarked hint should be visible
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(
|
||||
existing_bookmark.title, page.get_by_label("Title").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.description,
|
||||
page.get_by_label("Description").input_value(),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.notes, page.get_by_label("Notes").input_value()
|
||||
)
|
||||
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
|
||||
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
|
||||
|
||||
# Enter non-bookmarked URL
|
||||
page.get_by_label("URL").fill("https://example.com/unknown")
|
||||
# Already bookmarked hint should be hidden
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||
state="hidden", timeout=2000
|
||||
)
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
notes="Existing notes", description="Existing description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
|
||||
details = page.locator("details.notes")
|
||||
expect(details).not_to_have_attribute("open", value="")
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
url = (
|
||||
reverse("bookmarks:new")
|
||||
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
)
|
||||
page = self.open(url, p)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
@@ -1,9 +1,7 @@
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect, Locator
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
@@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
||||
expect(modal.locator("h2")).to_have_text("Tags")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export class ApiClient {
|
||||
export class Api {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
@@ -27,3 +27,6 @@ export class ApiClient {
|
||||
.then((data) => data.results);
|
||||
}
|
||||
}
|
||||
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
export const api = new Api(apiBaseUrl);
|
||||
|
@@ -5,9 +5,10 @@ class BookmarkItem extends Behavior {
|
||||
super(element);
|
||||
|
||||
// Toggle notes
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||
this.notesToggle = element.querySelector(".toggle-notes");
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
|
||||
// Add tooltip to title if it is truncated
|
||||
@@ -20,6 +21,12 @@ class BookmarkItem extends Behavior {
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.removeEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
@@ -4,16 +4,22 @@ class BulkEdit extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.active = false;
|
||||
this.active = element.classList.contains("active");
|
||||
|
||||
this.init = this.init.bind(this);
|
||||
this.onToggleActive = this.onToggleActive.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.init();
|
||||
// Reset when bookmarks are refreshed
|
||||
document.addEventListener("refresh-bookmark-list-done", () => this.init());
|
||||
// Reset when bookmarks are updated
|
||||
document.addEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.removeListeners();
|
||||
document.removeEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -31,13 +37,9 @@ class BulkEdit extends Behavior {
|
||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
// Remove previous listeners if elements are the same
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
||||
this.removeListeners();
|
||||
this.addListeners();
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
@@ -47,8 +49,9 @@ class BulkEdit extends Behavior {
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
// Add new listeners
|
||||
addListeners() {
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
@@ -57,6 +60,15 @@ class BulkEdit extends Behavior {
|
||||
});
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
onToggleActive() {
|
||||
this.active = !this.active;
|
||||
if (this.active) {
|
||||
|
42
bookmarks/frontend/behaviors/clear-button.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ClearButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.field = document.getElementById(element.dataset.for);
|
||||
if (!this.field) {
|
||||
console.error(`Field with ID ${element.dataset.for} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.update = this.update.bind(this);
|
||||
this.clear = this.clear.bind(this);
|
||||
|
||||
this.element.addEventListener("click", this.clear);
|
||||
this.field.addEventListener("input", this.update);
|
||||
this.field.addEventListener("value-changed", this.update);
|
||||
this.update();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.field) {
|
||||
return;
|
||||
}
|
||||
this.element.removeEventListener("click", this.clear);
|
||||
this.field.removeEventListener("input", this.update);
|
||||
this.field.removeEventListener("value-changed", this.update);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.element.style.display = this.field.value ? "inline-flex" : "none";
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.field.value = "";
|
||||
this.field.focus();
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-clear-button", ClearButtonBehavior);
|
@@ -3,17 +3,14 @@ import { Behavior, registerBehavior } from "./index";
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
element.dataset.type = element.type;
|
||||
element.dataset.name = element.name;
|
||||
element.dataset.value = element.value;
|
||||
element.removeAttribute("type");
|
||||
element.removeAttribute("name");
|
||||
element.removeAttribute("value");
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Behavior.interacting = false;
|
||||
this.reset();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
@@ -53,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.dataset.type;
|
||||
confirmButton.name = this.element.dataset.name;
|
||||
confirmButton.value = this.element.dataset.value;
|
||||
confirmButton.type = this.element.type;
|
||||
confirmButton.name = this.element.name;
|
||||
confirmButton.value = this.element.value;
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = buttonClasses;
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
@@ -70,7 +67,10 @@ class ConfirmButtonBehavior extends Behavior {
|
||||
reset() {
|
||||
setTimeout(() => {
|
||||
Behavior.interacting = false;
|
||||
this.container.remove();
|
||||
if (this.container) {
|
||||
this.container.remove();
|
||||
this.container = null;
|
||||
}
|
||||
this.element.classList.remove("d-none");
|
||||
});
|
||||
}
|
||||
|
62
bookmarks/frontend/behaviors/details-modal.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class DetailsModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||
|
||||
this.overlayLink.addEventListener("click", this.onClose);
|
||||
this.buttonLink.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlayLink.removeEventListener("click", this.onClose);
|
||||
this.buttonLink.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
|
||||
const closeUrl = this.overlayLink.href;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-details-modal", DetailsModalBehavior);
|
@@ -4,16 +4,16 @@ class DropdownBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
|
||||
const toggle = element.querySelector(".dropdown-toggle");
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
});
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
open() {
|
||||
@@ -26,6 +26,14 @@ class DropdownBehavior extends Behavior {
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.close();
|
||||
|
@@ -1,48 +0,0 @@
|
||||
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
||||
|
||||
class FetchBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const eventName = element.getAttribute("ld-on");
|
||||
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
||||
|
||||
this.onFetch = this.onFetch.bind(this);
|
||||
this.onInterval = this.onInterval.bind(this);
|
||||
|
||||
element.addEventListener(eventName, this.onFetch);
|
||||
if (interval) {
|
||||
this.intervalId = setInterval(this.onInterval, interval);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
async onFetch(maybeEvent) {
|
||||
if (maybeEvent) {
|
||||
maybeEvent.preventDefault();
|
||||
}
|
||||
const url = this.element.getAttribute("ld-fetch");
|
||||
const html = await fetch(url).then((response) => response.text());
|
||||
|
||||
const target = this.element.getAttribute("ld-target");
|
||||
const select = this.element.getAttribute("ld-select");
|
||||
swap(this.element, html, { target, select });
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
fireEvents(events);
|
||||
}
|
||||
|
||||
onInterval() {
|
||||
if (Behavior.interacting) {
|
||||
return;
|
||||
}
|
||||
this.onFetch();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-fetch", FetchBehavior);
|
@@ -1,64 +1,55 @@
|
||||
import { Behavior, fireEvents, registerBehavior } from "./index";
|
||||
|
||||
class FormBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("submit", this.onSubmit.bind(this));
|
||||
}
|
||||
|
||||
async onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = this.element.action;
|
||||
const formData = new FormData(this.element);
|
||||
if (event.submitter) {
|
||||
formData.append(event.submitter.name, event.submitter.value);
|
||||
}
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual", // ignore redirect
|
||||
});
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
if (fireEvents) {
|
||||
fireEvents(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("change", () => {
|
||||
const form = element.closest("form");
|
||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
});
|
||||
this.submit = this.submit.bind(this);
|
||||
element.addEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.element.closest("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.fileInput = element.nextElementSibling;
|
||||
|
||||
const fileInput = element.nextElementSibling;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
|
||||
element.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
element.addEventListener("click", this.onClick);
|
||||
this.fileInput.addEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const form = fileInput.closest("form");
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
event.submitter = element;
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
this.fileInput.removeEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
onChange() {
|
||||
// Check if the file input has a file selected
|
||||
if (!this.fileInput.files.length) {
|
||||
return;
|
||||
}
|
||||
const form = this.fileInput.closest("form");
|
||||
form.requestSubmit(this.element);
|
||||
// remove selected file so it doesn't get submitted again
|
||||
this.fileInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form", FormBehavior);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
|
@@ -4,7 +4,12 @@ class GlobalShortcuts extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
|
@@ -16,9 +16,34 @@ const mutationObserver = new MutationObserver((mutations) => {
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
// Update behaviors on Turbo events
|
||||
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
|
||||
// - turbo:render: after page navigation, including back/forward, and failed form submissions
|
||||
// - turbo:before-cache: before page navigation, reset DOM before caching
|
||||
document.addEventListener(
|
||||
"turbo:load",
|
||||
() => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
document.addEventListener("turbo:render", () => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:before-cache", () => {
|
||||
destroyBehaviors(document.body);
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
@@ -33,7 +58,6 @@ Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
applyBehaviors(document, [name]);
|
||||
}
|
||||
|
||||
export function applyBehaviors(container, behaviorNames = null) {
|
||||
@@ -95,51 +119,3 @@ export function destroyBehaviors(element) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html, options) {
|
||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
let targetElement = element;
|
||||
let strategy = "innerHTML";
|
||||
if (options.target) {
|
||||
const parts = options.target.split("|");
|
||||
targetElement =
|
||||
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
||||
strategy = parts[1] || "innerHTML";
|
||||
}
|
||||
|
||||
let contents = Array.from(dom.body.children);
|
||||
if (options.select) {
|
||||
contents = Array.from(dom.querySelectorAll(options.select));
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case "append":
|
||||
targetElement.append(...contents);
|
||||
break;
|
||||
case "outerHTML":
|
||||
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
||||
break;
|
||||
case "innerHTML":
|
||||
default:
|
||||
Array.from(targetElement.children).forEach((child) => {
|
||||
child.remove();
|
||||
});
|
||||
targetElement.append(...contents);
|
||||
}
|
||||
}
|
||||
|
||||
export function fireEvents(events) {
|
||||
if (!events) {
|
||||
return;
|
||||
}
|
||||
events.split(",").forEach((eventName) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll(`[ld-on='${eventName}']`),
|
||||
);
|
||||
targets.push(document);
|
||||
targets.forEach((target) => {
|
||||
target.dispatchEvent(new CustomEvent(eventName));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -1,51 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
const modalOverlay = element.querySelector(".modal-overlay");
|
||||
const closeButton = element.querySelector("button.close");
|
||||
modalOverlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener("animationend", (event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-modal", ModalBehavior);
|
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||
|
||||
class SearchAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warn("SearchAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new SearchAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
name: "q",
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
value: input.value,
|
||||
linkTarget: input.dataset.linkTarget,
|
||||
mode: input.dataset.mode,
|
||||
search: {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.autocomplete.replaceWith(this.input);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-search-autocomplete", SearchAutocomplete);
|
@@ -1,27 +1,35 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import { ApiClient } from "../api";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const wrapper = document.createElement("div");
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
const apiClient = new ApiClient(apiBaseUrl);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warn("TagAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: wrapper,
|
||||
target: container,
|
||||
props: {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
value: element.value,
|
||||
placeholder: element.getAttribute("placeholder") || "",
|
||||
apiClient: apiClient,
|
||||
variant: element.getAttribute("variant"),
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
||||
element.replaceWith(wrapper.firstElementChild);
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.autocomplete.replaceWith(this.input);
|
||||
}
|
||||
}
|
||||
|
||||
|
68
bookmarks/frontend/behaviors/tag-modal.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class TagModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>Tags</h2>
|
||||
<button class="close" aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tagCloud = document.querySelector(".tag-cloud");
|
||||
const tagCloudContainer = tagCloud.parentElement;
|
||||
|
||||
const content = modal.querySelector(".content");
|
||||
content.appendChild(tagCloud);
|
||||
|
||||
const overlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".close");
|
||||
overlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
this.modal = modal;
|
||||
this.tagCloud = tagCloud;
|
||||
this.tagCloudContainer = tagCloudContainer;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.remove();
|
||||
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-modal", TagModalBehavior);
|
35
bookmarks/frontend/cache.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { api } from "./api.js";
|
||||
|
||||
class Cache {
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
|
||||
// Reset cached tags after a form submission
|
||||
document.addEventListener("turbo:submit-end", () => {
|
||||
this.tagsPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
getTags() {
|
||||
if (!this.tagsPromise) {
|
||||
this.tagsPromise = this.api
|
||||
.getTags({
|
||||
limit: 5000,
|
||||
offset: 0,
|
||||
})
|
||||
.then((tags) =>
|
||||
tags.sort((left, right) =>
|
||||
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.catch((e) => {
|
||||
console.warn("Cache: Error loading tags", e);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return this.tagsPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = new Cache(api);
|
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import {SearchHistory} from "./SearchHistory";
|
||||
import {api} from "../api";
|
||||
import {cache} from "../cache";
|
||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
const searchHistory = new SearchHistory()
|
||||
@@ -7,9 +9,7 @@
|
||||
export let name;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let tags;
|
||||
export let mode = '';
|
||||
export let apiClient;
|
||||
export let search;
|
||||
export let linkTarget = '_blank';
|
||||
|
||||
@@ -88,17 +88,18 @@
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
const tags = await cache.getTags();
|
||||
let tagSuggestions = []
|
||||
const currentWord = getCurrentWord(input)
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||
const searchTag = currentWord.substring(1, currentWord.length)
|
||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
.slice(0, 5)
|
||||
.map(tagName => ({
|
||||
.map(tag => ({
|
||||
type: 'tag',
|
||||
index: nextIndex(),
|
||||
label: `#${tagName}`,
|
||||
tagName: tagName
|
||||
label: `#${tag.name}`,
|
||||
tagName: tag.name
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -119,9 +120,9 @@
|
||||
...search,
|
||||
q: value
|
||||
}
|
||||
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||
const fullLabel = bookmark.title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
return {
|
||||
type: 'bookmark',
|
||||
|
@@ -1,14 +1,13 @@
|
||||
<script>
|
||||
import {cache} from "../cache";
|
||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let apiClient;
|
||||
export let variant = 'default';
|
||||
|
||||
let tags = [];
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
@@ -17,18 +16,6 @@
|
||||
let suggestions = [];
|
||||
let selectedIndex = 0;
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
// For now we cache all tags on load as the template did before
|
||||
try {
|
||||
tags = await apiClient.getTags({limit: 5000, offset: 0});
|
||||
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
||||
} catch (e) {
|
||||
console.warn('TagAutocomplete: Error loading tag list');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
@@ -38,9 +25,10 @@
|
||||
close();
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
async function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word
|
||||
|
@@ -1,12 +1,17 @@
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/fetch";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { ApiClient } from "./api";
|
||||
export { api } from "./api";
|
||||
export { cache } from "./cache";
|
||||
|
@@ -8,28 +8,33 @@ class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||
|
||||
|
||||
default_global_settings = GlobalSettings()
|
||||
|
||||
standard_profile = UserProfile()
|
||||
standard_profile.enable_favicons = True
|
||||
|
||||
|
||||
class UserProfileMiddleware:
|
||||
class LinkdingMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# add global settings to request
|
||||
try:
|
||||
global_settings = GlobalSettings.get()
|
||||
except:
|
||||
global_settings = default_global_settings
|
||||
request.global_settings = global_settings
|
||||
|
||||
# add user profile to request
|
||||
if request.user.is_authenticated:
|
||||
request.user_profile = request.user.profile
|
||||
else:
|
||||
# check if a custom profile for guests exists, otherwise use standard profile
|
||||
guest_profile = None
|
||||
try:
|
||||
global_settings = GlobalSettings.get()
|
||||
if global_settings.guest_profile_user:
|
||||
guest_profile = global_settings.guest_profile_user.profile
|
||||
except:
|
||||
pass
|
||||
|
||||
request.user_profile = guest_profile or standard_profile
|
||||
if global_settings.guest_profile_user:
|
||||
request.user_profile = global_settings.guest_profile_user.profile
|
||||
else:
|
||||
request.user_profile = standard_profile
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
|
@@ -0,0 +1,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),
|
||||
),
|
||||
]
|
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.8 on 2024-09-18 20:11
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="items_per_page",
|
||||
field=models.IntegerField(
|
||||
default=30, validators=[django.core.validators.MinValueValidator(10)]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="sticky_pagination",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
36
bookmarks/migrations/0041_merge_metadata.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-21 08:13
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Bookmark.objects.filter(
|
||||
Q(title__isnull=True) | Q(title__exact=""),
|
||||
).extra(
|
||||
where=["website_title IS NOT NULL"]
|
||||
).update(title=RawSQL("website_title", ()))
|
||||
|
||||
Bookmark.objects.filter(
|
||||
Q(description__isnull=True) | Q(description__exact=""),
|
||||
).extra(where=["website_description IS NOT NULL"]).update(
|
||||
description=RawSQL("website_description", ())
|
||||
)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0040_userprofile_items_per_page_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
@@ -1,12 +1,13 @@
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import binascii
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -55,7 +56,9 @@ class Bookmark(models.Model):
|
||||
title = models.CharField(max_length=512, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
# Obsolete field, kept to not remove column when generating migrations
|
||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||
# Obsolete field, kept to not remove column when generating migrations
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||
favicon_file = models.CharField(max_length=512, blank=True)
|
||||
@@ -73,14 +76,12 @@ class Bookmark(models.Model):
|
||||
def resolved_title(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
elif self.website_title:
|
||||
return self.website_title
|
||||
else:
|
||||
return self.url
|
||||
|
||||
@property
|
||||
def resolved_description(self):
|
||||
return self.website_description if not self.description else self.description
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def tag_names(self):
|
||||
@@ -140,14 +141,9 @@ class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description in form as we fill these automatically if they are empty
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
# Include website title and description as hidden field as they only provide info when editing bookmarks
|
||||
website_title = forms.CharField(
|
||||
max_length=512, required=False, widget=forms.HiddenInput()
|
||||
)
|
||||
website_description = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
@@ -161,8 +157,6 @@ class BookmarkForm(forms.ModelForm):
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
@@ -282,7 +276,7 @@ class BookmarkSearchForm(forms.Form):
|
||||
]
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
@@ -422,6 +416,10 @@ class UserProfile(models.Model):
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||
items_per_page = models.IntegerField(
|
||||
null=False, default=30, validators=[MinValueValidator(10)]
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -450,6 +448,8 @@ class UserProfileForm(forms.ModelForm):
|
||||
"default_mark_unread",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
]
|
||||
|
||||
|
||||
@@ -514,6 +514,7 @@ class GlobalSettings(models.Model):
|
||||
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):
|
||||
@@ -532,7 +533,7 @@ class GlobalSettings(models.Model):
|
||||
class GlobalSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GlobalSettings
|
||||
fields = ["landing_page", "guest_profile_user"]
|
||||
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
|
||||
|
@@ -53,8 +53,6 @@ def _base_bookmarks_query(
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(website_title__icontains=term)
|
||||
| Q(website_description__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
|
||||
@@ -87,13 +85,7 @@ def _base_bookmarks_query(
|
||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||
query_set = query_set.filter(shared=False)
|
||||
|
||||
# Sort by date added
|
||||
if search.sort == BookmarkSearch.SORT_ADDED_ASC:
|
||||
query_set = query_set.order_by("date_added")
|
||||
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
|
||||
query_set = query_set.order_by("-date_added")
|
||||
|
||||
# Sort by title
|
||||
# Sort
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
or search.sort == BookmarkSearch.SORT_TITLE_DESC
|
||||
@@ -103,10 +95,6 @@ def _base_bookmarks_query(
|
||||
query_set = query_set.annotate(
|
||||
effective_title=Case(
|
||||
When(Q(title__isnull=False) & ~Q(title__exact=""), then=Lower("title")),
|
||||
When(
|
||||
Q(website_title__isnull=False) & ~Q(website_title__exact=""),
|
||||
then=Lower("website_title"),
|
||||
),
|
||||
default=Lower("url"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
@@ -124,6 +112,11 @@ def _base_bookmarks_query(
|
||||
query_set = query_set.order_by(order_field)
|
||||
elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
|
||||
query_set = query_set.order_by(order_field).reverse()
|
||||
elif search.sort == BookmarkSearch.SORT_ADDED_ASC:
|
||||
query_set = query_set.order_by("date_added")
|
||||
else:
|
||||
# Sort by date added, descending by default
|
||||
query_set = query_set.order_by("-date_added")
|
||||
|
||||
return query_set
|
||||
|
||||
|
@@ -26,8 +26,6 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
_merge_bookmark_data(bookmark, existing_bookmark)
|
||||
return update_bookmark(existing_bookmark, tag_string, current_user)
|
||||
|
||||
# Update website info
|
||||
_update_website_metadata(bookmark)
|
||||
# Set currently logged in user as owner
|
||||
bookmark.owner = current_user
|
||||
# Set dates
|
||||
@@ -67,13 +65,22 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||
# Only update website metadata if URL changed
|
||||
_update_website_metadata(bookmark)
|
||||
bookmark.save()
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
def enhance_with_website_metadata(bookmark: Bookmark):
|
||||
metadata = website_loader.load_website_metadata(bookmark.url)
|
||||
if not bookmark.title:
|
||||
bookmark.title = metadata.title or ""
|
||||
|
||||
if not bookmark.description:
|
||||
bookmark.description = metadata.description or ""
|
||||
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def archive_bookmark(bookmark: Bookmark):
|
||||
bookmark.is_archived = True
|
||||
bookmark.date_modified = timezone.now()
|
||||
@@ -235,12 +242,6 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.shared = from_bookmark.shared
|
||||
|
||||
|
||||
def _update_website_metadata(bookmark: Bookmark):
|
||||
metadata = website_loader.load_website_metadata(bookmark.url)
|
||||
bookmark.website_title = metadata.title
|
||||
bookmark.website_description = metadata.description
|
||||
|
||||
|
||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import subprocess
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SingeFileError(Exception):
|
||||
class SingleFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def create_snapshot(url: str, filepath: str):
|
||||
|
||||
# check if the file was created
|
||||
if not os.path.exists(temp_filepath):
|
||||
raise SingeFileError("Failed to create snapshot")
|
||||
raise SingleFileError("Failed to create snapshot")
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
@@ -47,12 +47,12 @@ def create_snapshot(url: str, filepath: str):
|
||||
)
|
||||
process.terminate()
|
||||
process.wait(timeout=20)
|
||||
raise SingeFileError("Timeout expired while creating snapshot")
|
||||
raise SingleFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the whole process group, which should also clean up any chromium
|
||||
# processes spawned by single-file
|
||||
logger.error("Timeout expired while terminating. Killing process...")
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
raise SingeFileError("Timeout expired while creating snapshot")
|
||||
raise SingleFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")
|
||||
raise SingleFileError(f"Failed to create snapshot: {error.stderr}")
|
||||
|
@@ -14,8 +14,8 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class WebsiteMetadata:
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
title: str | None
|
||||
description: str | None
|
||||
preview_image: str | None
|
||||
|
||||
def to_dict(self):
|
||||
@@ -43,7 +43,8 @@ def load_website_metadata(url: str):
|
||||
start = timezone.now()
|
||||
soup = BeautifulSoup(page_text, "html.parser")
|
||||
|
||||
title = soup.title.string.strip() if soup.title is not None else None
|
||||
if soup.title and soup.title.string:
|
||||
title = soup.title.string.strip()
|
||||
description_tag = soup.find("meta", attrs={"name": "description"})
|
||||
description = (
|
||||
description_tag["content"].strip()
|
||||
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 447 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
1
bookmarks/static/preview-placeholder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>
|
After Width: | Height: | Size: 535 B |
@@ -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;
|
||||
}
|
||||
}
|
131
bookmarks/styles/bookmark-details.css
Normal file
@@ -0,0 +1,131 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& a.weblink img,
|
||||
& a.weblink svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
& a.weblink span {
|
||||
flex: 1 1 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
margin: var(--unit-4 0);
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .asset-text .filesize {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
& .asset-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .assets-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .tags a {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
|
||||
& .status form {
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
& .status .form-group,
|
||||
.status .form-switch {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark details view specific */
|
||||
.bookmark-details.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-6);
|
||||
}
|
@@ -1,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;
|
||||
}
|
||||
}
|
39
bookmarks/styles/bookmark-form.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.bookmarks-form-page {
|
||||
section {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-form {
|
||||
& .has-icon-right > input,
|
||||
& .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
& .form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& .form-group .clear-button {
|
||||
display: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
height: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
& .form-input-hint.auto-tags {
|
||||
display: none;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
& details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
@@ -1,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;
|
||||
}
|
||||
}
|
524
bookmarks/styles/bookmark-page.css
Normal file
@@ -0,0 +1,524 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: var(--unit-h);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--border-color);
|
||||
object-fit: cover;
|
||||
|
||||
&.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--body-color-contrast);
|
||||
|
||||
& .img {
|
||||
width: var(--unit-12);
|
||||
height: var(--unit-12);
|
||||
background-color: var(--tertiary-text-color);
|
||||
-webkit-mask: url(preview-placeholder.svg) no-repeat center;
|
||||
mask: url(preview-placeholder.svg) no-repeat center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .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;
|
||||
}
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
background: var(--body-color);
|
||||
padding-bottom: var(--unit-h);
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(
|
||||
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||
);
|
||||
width: calc(
|
||||
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
||||
);
|
||||
background: var(--body-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .pagination {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* make sticky pagination expand to cover checkboxes to the left */
|
||||
|
||||
&.active .bookmark-pagination.sticky:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
left: calc(
|
||||
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||
);
|
||||
width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset));
|
||||
background: var(--body-color);
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox.all {
|
||||
display: block;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
margin: 0 0 0 var(--bulk-edit-toggle-offset);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
|
||||
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
min-height: var(--bulk-edit-toggle-width);
|
||||
left: calc(
|
||||
-1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset)
|
||||
);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all var(--bulk-edit-transition-duration);
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
& .bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--unit-1) 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
gap: var(--unit-2);
|
||||
|
||||
& button {
|
||||
--control-padding-x-sm: 0;
|
||||
}
|
||||
|
||||
& button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
& > input,
|
||||
& .form-autocomplete,
|
||||
& select {
|
||||
width: auto;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
& .select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,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;
|
||||
}
|
||||
}
|
||||
}
|
65
bookmarks/styles/components.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* Shared components */
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
section.content-area .content-area-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
color: var(--error-color) !important;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-5) 0;
|
||||
}
|
||||
|
||||
/* Turbo progress bar */
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
39
bookmarks/styles/layout.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
46
bookmarks/styles/markdown.css
Normal file
@@ -0,0 +1,46 @@
|
||||
.markdown {
|
||||
& p,
|
||||
& ul,
|
||||
& ol,
|
||||
& pre,
|
||||
& blockquote {
|
||||
margin: 0 0 var(--unit-2) 0;
|
||||
}
|
||||
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& ul,
|
||||
& ol {
|
||||
margin-left: var(--unit-4);
|
||||
}
|
||||
|
||||
& ul li,
|
||||
& ol li {
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
|
||||
& pre {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
background-color: var(--code-bg-color);
|
||||
border-radius: var(--unit-1);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
& pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& > pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
.markdown {
|
||||
p, ul, ol, pre, blockquote {
|
||||
margin: 0 0 $unit-2 0;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: $unit-4;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: $unit-1 $unit-2;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: $unit-1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
@@ -24,4 +24,3 @@ html.reader-mode {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,3 @@
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: $size-lg;
|
||||
}
|
||||
|
||||
.show-sm,
|
||||
.show-md {
|
||||
display: none !important;
|
||||
@@ -26,11 +19,18 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: var(--size-lg);
|
||||
}
|
||||
|
||||
.grid {
|
||||
--grid-columns: 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||
grid-gap: $unit-4;
|
||||
grid-gap: var(--unit-4);
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
@@ -46,18 +46,18 @@
|
||||
}
|
||||
|
||||
.col-1 {
|
||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||
grid-column: span min(1, var(--grid-columns));
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
grid-column: unquote("span min(2, var(--grid-columns))");
|
||||
grid-column: span min(2, var(--grid-columns));
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
grid-column: unquote("span min(3, var(--grid-columns))");
|
||||
grid-column: span min(3, var(--grid-columns));
|
||||
}
|
||||
|
||||
@media (max-width: $size-md) {
|
||||
@media (max-width: 840px) {
|
||||
.hide-md {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -86,7 +86,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
@media (max-width: 600px) {
|
||||
.hide-sm {
|
||||
display: none !important;
|
||||
}
|
@@ -1,9 +1,9 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: $unit-10;
|
||||
margin-bottom: var(--unit-10);
|
||||
|
||||
h2 {
|
||||
margin-bottom: $unit-3;
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
.input-group > input[type="submit"] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
section.about table {
|
||||
max-width: 500px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
& .form-group {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
143
bookmarks/styles/theme-dark.css
Normal file
@@ -0,0 +1,143 @@
|
||||
@import "theme-light.css";
|
||||
|
||||
:root {
|
||||
/* Color palette */
|
||||
--contrast-5: hsla(241, 65%, 85%, 0.06);
|
||||
--contrast-10: hsla(241, 60%, 80%, 0.14);
|
||||
--contrast-20: hsla(241, 64%, 82%, 0.23);
|
||||
--contrast-30: hsla(241, 69%, 84%, 0.32);
|
||||
--contrast-40: hsla(241, 73%, 86%, 0.41);
|
||||
--contrast-50: hsla(241, 78%, 88%, 0.5);
|
||||
--contrast-60: hsla(241, 82%, 90%, 0.58);
|
||||
--contrast-70: hsla(241, 87%, 92%, 0.69);
|
||||
--contrast-80: hsla(241, 91%, 94%, 0.8);
|
||||
--contrast-90: hsla(241, 96%, 96%, 0.9);
|
||||
|
||||
--primary-color: hsl(241, 75%, 64%);
|
||||
--primary-color-highlight: hsl(241, 75%, 68%);
|
||||
--primary-color-shade: hsl(241, 75%, 64%, 0.42);
|
||||
|
||||
--alternative-color: hsl(179, 50%, 58%);
|
||||
--alternative-color-dark: hsl(179, 80%, 75%);
|
||||
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
|
||||
--error-color: hsl(0, 80%, 60%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-300);
|
||||
--secondary-text-color: var(--gray-400);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 82%, 82%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 82%, 82%, 0.8);
|
||||
|
||||
--icon-color: var(--text-color);
|
||||
|
||||
--border-color: var(--contrast-30);
|
||||
--secondary-border-color: var(--contrast-20);
|
||||
|
||||
--body-color: hsl(241, 15%, 14%);
|
||||
--body-color-contrast: var(--contrast-10);
|
||||
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid hsl(241, 100%, 78%);
|
||||
--focus-outline-offset: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--box-shadow-xs: none;
|
||||
--box-shadow: none;
|
||||
--box-shadow-lg: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--input-bg-color: var(--contrast-5);
|
||||
--input-disabled-bg-color: var(--contrast-30);
|
||||
--input-text-color: var(--text-color);
|
||||
--input-hint-color: var(--secondary-text-color);
|
||||
--input-border-color: var(--border-color);
|
||||
--input-placeholder-color: var(--tertiary-text-color);
|
||||
--input-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--checkbox-bg-color: var(--contrast-10);
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-disabled-bg-color: var(--contrast-30);
|
||||
--checkbox-border-color: var(--border-color);
|
||||
--checkbox-icon-color: #fff;
|
||||
|
||||
--switch-bg-color: var(--contrast-10);
|
||||
--switch-border-color: var(--border-color);
|
||||
--switch-toggle-color: var(--text-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--btn-bg-color: var(--contrast-5);
|
||||
--btn-hover-bg-color: var(--contrast-20);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);
|
||||
--modal-container-bg-color: hsl(241, 20%, 20%);
|
||||
--modal-container-border-color: var(--contrast-30);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-box-shadow: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--menu-bg-color: hsl(241, 20%, 20%);
|
||||
--menu-border-color: var(--contrast-30);
|
||||
--menu-border-radius: var(--border-radius);
|
||||
--menu-box-shadow: none;
|
||||
--menu-item-color: var(--text-color);
|
||||
--menu-item-hover-color: var(--text-color);
|
||||
--menu-item-bg-color: transparent;
|
||||
--menu-item-hover-bg-color: var(--contrast-20);
|
||||
}
|
||||
|
||||
:root {
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bookmark-title-color: var(--primary-text-color);
|
||||
--bookmark-title-weight: 500;
|
||||
--bookmark-description-color: var(--text-color);
|
||||
--bookmark-description-weight: 400;
|
||||
--bookmark-actions-color: var(--secondary-text-color);
|
||||
--bookmark-actions-hover-color: var(--text-color);
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--contrast-5);
|
||||
}
|
@@ -1,66 +0,0 @@
|
||||
// Import custom variables
|
||||
@import "variables-dark";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
@import "reader-mode";
|
||||
|
||||
/* Dark theme overrides */
|
||||
|
||||
// Buttons
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus ring
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-input:not(:placeholder-shown):invalid,
|
||||
.form-input:not(:placeholder-shown):invalid:focus,
|
||||
.has-error .form-input,
|
||||
.form-input.is-error,
|
||||
.has-error .form-select,
|
||||
.form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-input-color;
|
||||
border-color: $dt-primary-input-color;
|
||||
}
|
||||
|
||||
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
|
||||
background: $light-color;
|
||||
}
|
||||
|
||||
.form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-input-color;
|
||||
border-color: $dt-primary-input-color;
|
||||
}
|
||||
|
||||
.form-radio input:checked + .form-icon::before {
|
||||
background: $light-color;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
30
bookmarks/styles/theme-light.css
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "theme/variables.css";
|
||||
@import "theme/_normalize.css";
|
||||
@import "theme/base.css";
|
||||
@import "theme/typography.css";
|
||||
@import "theme/asian.css";
|
||||
@import "theme/tables.css";
|
||||
@import "theme/buttons.css";
|
||||
@import "theme/forms.css";
|
||||
@import "theme/code.css";
|
||||
@import "theme/dropdowns.css";
|
||||
@import "theme/menus.css";
|
||||
@import "theme/badges.css";
|
||||
@import "theme/empty.css";
|
||||
@import "theme/modals.css";
|
||||
@import "theme/pagination.css";
|
||||
@import "theme/tabs.css";
|
||||
@import "theme/toasts.css";
|
||||
@import "theme/autocomplete.css";
|
||||
@import "theme/animations.css";
|
||||
@import "theme/utilities.css";
|
||||
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
@@ -1,15 +0,0 @@
|
||||
// Import custom variables
|
||||
@import "variables-light";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
@import "reader-mode";
|
21
bookmarks/styles/theme/LICENSE
Normal file
@@ -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.
|
448
bookmarks/styles/theme/_normalize.css
Normal file
@@ -0,0 +1,448 @@
|
||||
/* Manually forked from Normalize.css */
|
||||
/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/**
|
||||
* 1. Change the default font family in all browsers (opinionated).
|
||||
* 2. Correct the line height in all browsers.
|
||||
* 3. Prevent adjustments of font size after orientation changes in
|
||||
* IE on Windows Phone and in iOS.
|
||||
*/
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
html {
|
||||
font-family: sans-serif; /* 1 */
|
||||
-ms-text-size-adjust: 100%; /* 3 */
|
||||
-webkit-text-size-adjust: 100%; /* 3 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
footer,
|
||||
header,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
figcaption,
|
||||
figure,
|
||||
main {
|
||||
/* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct margin in IE 8 (removed).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers. (removed)
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the gray background on active links in IE 10.
|
||||
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent; /* 1 */
|
||||
-webkit-text-decoration-skip: objects; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the outline on focused links when they are also active or hovered
|
||||
* in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
a:active,
|
||||
a:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify default styling of address.
|
||||
*/
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Firefox 39-.
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: var(--mono-font-family); /* 1 (changed) */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font style in Android 4.3-.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct background and color in IE 9-. (Removed)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
font-weight: 400; /* (added) */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
audio,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in iOS 4-7.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10-.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overflow in IE.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers (opinionated).
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 (changed) */
|
||||
font-size: inherit; /* 1 (changed) */
|
||||
line-height: inherit; /* 1 (changed) */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input {
|
||||
/* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
/* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
|
||||
* controls in Android 4.
|
||||
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
html [type="button"], /* 1 */
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule (removed).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Change the border, margin, and padding in all browsers (opinionated) (changed).
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct display in IE 9-.
|
||||
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
display: inline-block; /* 1 */
|
||||
vertical-align: baseline; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10-.
|
||||
* 2. Remove the padding in IE 10-.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||
*/
|
||||
|
||||
details, /* 1 */
|
||||
menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scripting
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
canvas {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hidden
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10-.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
38
bookmarks/styles/theme/animations.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Animations */
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(-1 * var(--unit-8)));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
43
bookmarks/styles/theme/asian.css
Normal file
@@ -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: 0.125em;
|
||||
}
|
||||
}
|
57
bookmarks/styles/theme/autocomplete.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* Autocomplete */
|
||||
.form-autocomplete {
|
||||
position: relative;
|
||||
|
||||
& .form-autocomplete-input {
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: calc(var(--focus-outline-offset) * -1);
|
||||
}
|
||||
|
||||
& .form-input {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .menu {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
|
||||
& .menu-item.selected > a,
|
||||
& .menu-item > a:hover {
|
||||
background: var(--menu-item-hover-bg-color);
|
||||
color: var(--menu-item-hover-color);
|
||||
}
|
||||
|
||||
& .group-item,
|
||||
& .group-item:hover {
|
||||
color: var(--tertiary-text-color);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
64
bookmarks/styles/theme/badges.css
Normal file
@@ -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: 0.5rem;
|
||||
box-shadow: 0 0 0 1px var(--body-color);
|
||||
color: var(--contrast-text-color);
|
||||
content: attr(data-badge);
|
||||
display: inline-block;
|
||||
transform: translate(-0.05rem, -0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-badge] {
|
||||
&::after {
|
||||
font-size: var(--font-size-sm);
|
||||
height: 0.9rem;
|
||||
line-height: 1;
|
||||
min-width: 0.9rem;
|
||||
padding: 0.1rem 0.2rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-badge]),
|
||||
&[data-badge=""] {
|
||||
&::after {
|
||||
height: 6px;
|
||||
min-width: 6px;
|
||||
padding: 0;
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Buttons */
|
||||
|
||||
&.btn {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Avatars */
|
||||
|
||||
&.avatar {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 14.64%;
|
||||
right: 14.64%;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: var(--zindex-1);
|
||||
}
|
||||
}
|
||||
}
|
61
bookmarks/styles/theme/base.css
Normal file
@@ -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);
|
||||
}
|
268
bookmarks/styles/theme/buttons.css
Normal file
@@ -0,0 +1,268 @@
|
||||
/* Buttons */
|
||||
:root {
|
||||
--btn-bg-color: var(--body-color);
|
||||
--btn-hover-bg-color: var(--gray-50);
|
||||
--btn-border-color: var(--border-color);
|
||||
--btn-text-color: var(--text-color);
|
||||
--btn-icon-color: var(--icon-color);
|
||||
--btn-font-weight: 400;
|
||||
--btn-box-shadow: var(--box-shadow-xs);
|
||||
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-hover-bg-color: var(--primary-color-highlight);
|
||||
--btn-primary-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-success-bg-color: var(--success-color);
|
||||
--btn-success-hover-bg-color: var(--success-color-highlight);
|
||||
--btn-success-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-error-bg-color: var(--error-color);
|
||||
--btn-error-hover-bg-color: var(--error-color-highlight);
|
||||
--btn-error-text-color: var(--contrast-text-color);
|
||||
|
||||
--btn-link-text-color: var(--link-color);
|
||||
--btn-link-hover-text-color: var(--link-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
background: var(--btn-bg-color);
|
||||
border: var(--border-width) solid var(--btn-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--btn-text-color);
|
||||
font-weight: var(--btn-font-weight);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size);
|
||||
height: var(--control-size);
|
||||
line-height: var(--line-height);
|
||||
outline: none;
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
box-shadow: var(--btn-box-shadow);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border 0.2s,
|
||||
box-shadow 0.2s,
|
||||
color 0.2s;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-hover-bg-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Button Primary */
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--btn-primary-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-primary-text-color);
|
||||
--btn-icon-color: var(--btn-primary-text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-primary-hover-bg-color);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
&::after {
|
||||
border-bottom-color: var(--btn-primary-text-color);
|
||||
border-left-color: var(--btn-primary-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Colors */
|
||||
|
||||
&.btn-success {
|
||||
background: var(--btn-success-bg-color);
|
||||
border-color: transparent;
|
||||
color: var(--btn-success-text-color);
|
||||
--btn-icon-color: var(--btn-success-text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-success-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-error {
|
||||
--btn-border-color: var(--error-color);
|
||||
--btn-text-color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
--btn-hover-bg-color: var(--error-color-shade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--btn-link-text-color);
|
||||
--btn-icon-color: var(--btn-link-text-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-link-hover-text-color);
|
||||
--btn-icon-color: var(--btn-link-hover-text-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
||||
&.btn-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
height: var(--control-size-sm);
|
||||
padding: var(--control-padding-y-sm) var(--control-padding-x-sm);
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
height: var(--control-size-lg);
|
||||
padding: var(--control-padding-y-lg) var(--control-padding-x-lg);
|
||||
}
|
||||
|
||||
/* Button Block */
|
||||
|
||||
&.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Button Action */
|
||||
|
||||
&.btn-action {
|
||||
width: var(--control-size);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
&.btn-sm {
|
||||
width: var(--control-size-sm);
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
width: var(--control-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Clear */
|
||||
|
||||
&.btn-clear {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: currentColor;
|
||||
box-shadow: none;
|
||||
height: var(--unit-5);
|
||||
line-height: var(--unit-4);
|
||||
margin-left: var(--unit-1);
|
||||
margin-right: -2px;
|
||||
opacity: 1;
|
||||
padding: var(--unit-h);
|
||||
text-decoration: none;
|
||||
width: var(--unit-5);
|
||||
|
||||
&::before {
|
||||
content: "\2715";
|
||||
}
|
||||
}
|
||||
|
||||
/* Wider button */
|
||||
|
||||
&.btn-wide {
|
||||
padding-left: var(--unit-6);
|
||||
padding-right: var(--unit-6);
|
||||
}
|
||||
|
||||
/* Small icon button */
|
||||
|
||||
&.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-h);
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button icons */
|
||||
|
||||
& svg {
|
||||
color: var(--btn-icon-color);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button groups */
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 auto;
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:last-child:not(:first-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-left: calc(-1 * var(--border-width));
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-group-block {
|
||||
display: flex;
|
||||
|
||||
.btn {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
}
|
||||
}
|
30
bookmarks/styles/theme/code.css
Normal file
@@ -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: 0.1rem 0.2rem;
|
||||
background: var(--code-bg-color);
|
||||
color: var(--code-color);
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.code {
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--code-bg-color);
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
|
||||
& code {
|
||||
color: inherit;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
padding: var(--unit-2);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
36
bookmarks/styles/theme/dropdowns.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
.menu {
|
||||
animation: fade-in 0.15s ease 1;
|
||||
display: none;
|
||||
left: 0;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
&.dropdown-right {
|
||||
.menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu,
|
||||
.dropdown-toggle:focus + .menu,
|
||||
.menu:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fix dropdown-toggle border radius in button groups */
|
||||
.btn-group {
|
||||
.dropdown-toggle:nth-last-child(2) {
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
21
bookmarks/styles/theme/empty.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
537
bookmarks/styles/theme/forms.css
Normal file
@@ -0,0 +1,537 @@
|
||||
/* 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 0.35rem center / 0.4rem 0.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 0.2s,
|
||||
border 0.2s,
|
||||
color 0.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 0.2s,
|
||||
border 0.2s,
|
||||
color 0.2s,
|
||||
left 0.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;
|
||||
}
|
||||
}
|
90
bookmarks/styles/theme/menus.css
Normal file
@@ -0,0 +1,90 @@
|
||||
: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;
|
||||
}
|
||||
}
|
104
bookmarks/styles/theme/modals.css
Normal file
@@ -0,0 +1,104 @@
|
||||
/* 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 0.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 0.15s ease 1;
|
||||
z-index: var(--zindex-0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-overlay,
|
||||
& .modal-container {
|
||||
animation: fade-out 0.15s ease 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--modal-container-bg-color);
|
||||
border: solid 1px var(--modal-container-border-color);
|
||||
border-radius: var(--modal-border-radius);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
61
bookmarks/styles/theme/pagination.css
Normal file
@@ -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: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.page-prev,
|
||||
&.page-next {
|
||||
flex: 1 0 50%;
|
||||
}
|
||||
|
||||
&.page-next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .page-item-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .page-item-subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
26
bookmarks/styles/theme/tables.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/* Tables */
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
/* Scrollable tables */
|
||||
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
}
|
||||
}
|
76
bookmarks/styles/theme/tabs.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/* Tabs */
|
||||
:root {
|
||||
--tab-color: var(--text-color);
|
||||
--tab-hover-color: var(--primary-text-color);
|
||||
--tab-active-color: var(--primary-text-color);
|
||||
--tab-highlight-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab {
|
||||
align-items: center;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;
|
||||
|
||||
& .tab-item {
|
||||
margin-top: 0;
|
||||
|
||||
& a {
|
||||
border-bottom: var(--border-width-lg) solid transparent;
|
||||
color: var(--tab-color);
|
||||
display: block;
|
||||
margin: 0 var(--unit-2) 0 0;
|
||||
padding: var(--unit-2) var(--unit-1)
|
||||
calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--tab-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active a,
|
||||
& a.active {
|
||||
border-bottom-color: var(--tab-highlight-color);
|
||||
color: var(--tab-active-color);
|
||||
}
|
||||
|
||||
&.tab-action {
|
||||
flex: 1 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .btn-clear {
|
||||
margin-top: calc(-1 * var(--unit-1));
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-block {
|
||||
& .tab-item {
|
||||
flex: 1 0 0;
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .badge {
|
||||
&[data-badge]::after {
|
||||
position: absolute;
|
||||
right: var(--unit-h);
|
||||
top: var(--unit-h);
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.tab-block) {
|
||||
& .badge {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
35
bookmarks/styles/theme/toasts.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* Toasts */
|
||||
.toast {
|
||||
background: var(--gray-600);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--contrast-text-color);
|
||||
display: block;
|
||||
padding: var(--layout-spacing);
|
||||
width: 100%;
|
||||
|
||||
&.toast-primary {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
&.toast-success {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
&.toast-warning {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
&.toast-error {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
margin: var(--unit-h);
|
||||
}
|
||||
|
||||
p {
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
117
bookmarks/styles/theme/typography.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* Typography */
|
||||
/* Headings */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5em;
|
||||
margin-top: 0;
|
||||
}
|
||||
.h1,
|
||||
.h2,
|
||||
.h3,
|
||||
.h4,
|
||||
.h5,
|
||||
.h6 {
|
||||
font-weight: 500;
|
||||
}
|
||||
h1,
|
||||
.h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
h2,
|
||||
.h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
h3,
|
||||
.h3 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
h4,
|
||||
.h4 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h5,
|
||||
.h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6,
|
||||
.h6 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
margin: 0 0 var(--line-height);
|
||||
}
|
||||
|
||||
/* Semantic text elements */
|
||||
a,
|
||||
ins,
|
||||
u {
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: var(--border-width) dotted;
|
||||
cursor: help;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
blockquote {
|
||||
border-left: var(--border-width-lg) solid var(--border-color);
|
||||
margin-left: 0;
|
||||
padding: var(--unit-2) var(--unit-4);
|
||||
|
||||
& p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul,
|
||||
ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
padding: 0;
|
||||
|
||||
& ul,
|
||||
& ol {
|
||||
margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);
|
||||
}
|
||||
|
||||
& li {
|
||||
margin-top: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc inside;
|
||||
|
||||
& ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal inside;
|
||||
|
||||
& ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
& dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin: var(--unit-1) 0 var(--unit-4) 0;
|
||||
}
|
||||
}
|
296
bookmarks/styles/theme/utilities.css
Normal file
@@ -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: 0.9em;
|
||||
}
|
||||
|
||||
.text-tiny {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
opacity: 0.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;
|
||||
}
|
149
bookmarks/styles/theme/variables.css
Normal file
@@ -0,0 +1,149 @@
|
||||
:root {
|
||||
/* Color palette */
|
||||
--gray-50: rgb(249, 250, 251);
|
||||
--gray-100: rgb(243, 244, 246);
|
||||
--gray-200: rgb(229, 231, 235);
|
||||
--gray-300: rgb(209, 213, 219);
|
||||
--gray-400: rgb(156, 163, 175);
|
||||
--gray-500: rgb(107, 114, 128);
|
||||
--gray-600: rgb(75, 85, 99);
|
||||
--gray-700: rgb(55, 65, 81);
|
||||
--gray-800: rgb(31, 41, 55);
|
||||
--gray-900: rgb(17, 24, 39);
|
||||
|
||||
--primary-color: hsl(241, 63%, 59%);
|
||||
--primary-color-highlight: hsl(241, 63%, 64%);
|
||||
--primary-color-shade: hsl(241, 63%, 59%, 0.075);
|
||||
|
||||
--alternative-color: hsl(179, 94%, 29%);
|
||||
--alternative-color-dark: hsl(179, 94%, 22%);
|
||||
|
||||
--success-color: hsl(142, 76%, 36%);
|
||||
--success-color-highlight: hsl(142, 76%, 40%);
|
||||
--success-color-shade: hsla(142, 76%, 36%, 0.1);
|
||||
|
||||
--warning-color: hsl(38, 92%, 50%);
|
||||
--warning-color-highlight: hsl(38, 92%, 55%);
|
||||
--warning-color-shade: hsla(38, 92%, 50%, 0.1);
|
||||
|
||||
--error-color: hsl(0, 72%, 51%);
|
||||
--error-color-highlight: hsl(0, 72%, 60%);
|
||||
--error-color-shade: hsla(0, 72%, 51%, 0.1);
|
||||
|
||||
/* Core colors */
|
||||
--text-color: var(--gray-700);
|
||||
--secondary-text-color: var(--gray-500);
|
||||
--tertiary-text-color: var(--gray-500);
|
||||
--contrast-text-color: #fff;
|
||||
--primary-text-color: hsl(241, 63%, 55%);
|
||||
|
||||
--link-color: var(--primary-text-color);
|
||||
--secondary-link-color: hsla(241, 63%, 54%, 0.8);
|
||||
|
||||
--icon-color: var(--gray-500);
|
||||
|
||||
--border-color: var(--gray-300);
|
||||
--secondary-border-color: var(--gray-200);
|
||||
|
||||
--body-color: #fff;
|
||||
--body-color-contrast: var(--gray-100);
|
||||
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
|
||||
monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
|
||||
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
|
||||
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
|
||||
var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
|
||||
var(--fallback-font-family);
|
||||
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||
|
||||
/* Unit sizes */
|
||||
--unit-o: 0.05rem;
|
||||
--unit-h: 0.1rem;
|
||||
--unit-1: 0.2rem;
|
||||
--unit-2: 0.4rem;
|
||||
--unit-3: 0.6rem;
|
||||
--unit-4: 0.8rem;
|
||||
--unit-5: 1rem;
|
||||
--unit-6: 1.2rem;
|
||||
--unit-7: 1.4rem;
|
||||
--unit-8: 1.6rem;
|
||||
--unit-9: 1.8rem;
|
||||
--unit-10: 2rem;
|
||||
--unit-12: 2.4rem;
|
||||
--unit-16: 3.2rem;
|
||||
|
||||
/* Font sizes */
|
||||
--html-font-size: 20px;
|
||||
--html-line-height: 1.5;
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
--line-height: 1rem;
|
||||
|
||||
/* Sizes */
|
||||
--layout-spacing: var(--unit-2);
|
||||
--layout-spacing-sm: var(--unit-1);
|
||||
--layout-spacing-lg: var(--unit-4);
|
||||
--border-radius: var(--unit-1);
|
||||
--border-radius-lg: var(--unit-2);
|
||||
--border-width: var(--unit-o);
|
||||
--border-width-lg: var(--unit-h);
|
||||
--control-size: var(--unit-8);
|
||||
--control-size-sm: var(--unit-6);
|
||||
--control-size-lg: var(--unit-9);
|
||||
--control-padding-x: var(--unit-2);
|
||||
--control-padding-x-sm: calc(var(--unit-2) * 0.75);
|
||||
--control-padding-x-lg: calc(var(--unit-2) * 1.5);
|
||||
--control-padding-y: calc(
|
||||
(var(--control-size) - var(--line-height)) / 2 - var(--border-width)
|
||||
);
|
||||
--control-padding-y-sm: calc(
|
||||
(var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width)
|
||||
);
|
||||
--control-padding-y-lg: calc(
|
||||
(var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width)
|
||||
);
|
||||
--control-icon-size: 0.8rem;
|
||||
|
||||
--control-width-xs: 180px;
|
||||
--control-width-sm: 320px;
|
||||
--control-width-md: 640px;
|
||||
--control-width-lg: 960px;
|
||||
--control-width-xl: 1280px;
|
||||
|
||||
/* Responsive breakpoints */
|
||||
--size-xs: 480px;
|
||||
--size-sm: 600px;
|
||||
--size-md: 840px;
|
||||
--size-lg: 960px;
|
||||
--size-xl: 1280px;
|
||||
--size-2x: 1440px;
|
||||
|
||||
--responsive-breakpoint: var(--size-xs);
|
||||
|
||||
/* Z-index */
|
||||
--zindex-0: 1;
|
||||
--zindex-1: 100;
|
||||
--zindex-2: 200;
|
||||
--zindex-3: 300;
|
||||
--zindex-4: 400;
|
||||
|
||||
/* Focus */
|
||||
--focus-outline: 2px solid var(--primary-color);
|
||||
--focus-outline-offset: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
$body-bg: #161822 !default;
|
||||
$bg-color: lighten($body-bg, 5%) !default;
|
||||
$bg-color-light: lighten($body-bg, 5%) !default;
|
||||
|
||||
$border-color: #4C4E53 !default;
|
||||
$border-color-dark: $border-color !default;
|
||||
|
||||
$body-font-color: #b5bec8 !default;
|
||||
$light-color: #fafafa !default;
|
||||
|
||||
$gray-color: #7f879b !default;
|
||||
$gray-color-dark: lighten($gray-color, 20%) !default;
|
||||
|
||||
$primary-color: #a8b1ff !default;
|
||||
$primary-color-dark: saturate($primary-color, 5%) !default;
|
||||
$secondary-color: lighten($body-bg, 10%) !default;
|
||||
|
||||
$link-color: $primary-color !default;
|
||||
$link-color-dark: darken($link-color, 5%) !default;
|
||||
$link-color-light: $link-color !default;
|
||||
|
||||
$secondary-link-color: rgba(168, 177, 255, 0.73);
|
||||
|
||||
$alternative-color: #59bdb9;
|
||||
$alternative-color-dark: #73f1eb;
|
||||
|
||||
$code-bg-color: rgba(255, 255, 255, 0.1);
|
||||
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
/* Dark theme specific */
|
||||
$dt-primary-input-color: #5C68E7 !default;
|
||||
$dt-primary-button-color: #5761cb !default;
|
@@ -1,7 +0,0 @@
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
||||
|
||||
$secondary-link-color: rgba(87, 85, 217, 0.64);
|
||||
|
||||
$code-bg-color: rgba(0, 0, 0, 0.05);
|
||||
$code-shadow-color: rgba(0, 0, 0, 0.15);
|
@@ -11,24 +11,20 @@
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
<form class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
<div id="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
@@ -39,10 +35,16 @@
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -26,7 +26,7 @@
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -58,18 +58,18 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray">
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
@@ -78,9 +78,7 @@
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
@@ -144,14 +142,20 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% if bookmark_list.show_preview_images %}
|
||||
{% if bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% else %}
|
||||
<div class="preview-image placeholder">
|
||||
<div class="img"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{% load shared %}
|
||||
{% htmlmin %}
|
||||
<div class="bulk-edit-bar">
|
||||
<div class="bulk-edit-actions bg-gray">
|
||||
<div class="bulk-edit-actions">
|
||||
<label class="form-checkbox bulk-edit-checkbox all">
|
||||
<input type="checkbox">
|
||||
<i class="form-icon"></i>
|
||||
@@ -23,11 +23,12 @@
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none">
|
||||
<input ld-tag-autocomplete variant="small"
|
||||
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||
</div>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">
|
||||
<span>Execute</span>
|
||||
</button>
|
||||
|
||||
<label class="form-checkbox select-across d-none">
|
||||
<input type="checkbox" name="bulk_select_across">
|
||||
|
@@ -1,7 +1,9 @@
|
||||
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
||||
height="20px">
|
||||
<path
|
||||
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
|
||||
<path d="M16 5l3 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
|