diff --git a/README.md b/README.md index c0c8b39..3c7b839 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,13 @@ The name comes from: **Feature Overview:** - Clean UI optimized for readability - Organize bookmarks with tags -- Add notes using Markdown -- Read it later functionality -- Share bookmarks with other users -- Bulk editing +- Bulk editing, Markdown notes, read it later functionality +- Share bookmarks with other users or guests - Automatically provides titles, descriptions and icons of bookmarked websites -- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/) +- Automatically creates snapshots of websites, either as local HTML file or on Internet Archive - Import and export bookmarks in Netscape HTML format - Installable as a Progressive Web App (PWA) - Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet -- Light and dark themes - SSO support via OIDC or authentication proxies - REST API for developing 3rd party apps - Admin panel for user self-service and raw data access @@ -62,27 +59,45 @@ The Docker image is compatible with ARM platforms, so it can be run on a Raspber linkding uses an SQLite database by default. Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information. -
- -🧪 Alpine-based image - -The default Docker image (`latest` tag) is based on a slim variant of Debian Linux. -Alternatively, there is an image based on Alpine Linux (`latest-alpine` tag) which has a smaller size, resulting in a smaller download and less disk space required. -The Alpine image is currently about 45 MB in compressed size, compared to about 130 MB for the Debian image. - -To use it, replace the `latest` tag with `latest-alpine`, either in the CLI command below when using Docker, or in the `docker-compose.yml` file when using docker-compose. - -> [!WARNING] -> The image is currently considered experimental in order to gather feedback and iron out any issues. -> Only use it if you are comfortable running experimental software or want to help out with testing. -> While there should be no issues with creating new installations, there might be issues when migrating existing installations. -> If you plan to migrate your existing installation, make sure to create proper [backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) first. - -
- ### Using Docker -To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub: +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. + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagDescription
latestProvides the basic functionality of linkding
latest-plus + Includes feature for saving HTML snapshots of websites +
    +
  • Significantly larger image size as it includes a Chromium installation
  • +
  • Requires more runtime memory to run Chromium
  • +
  • Requires more disk space for storing HTML snapshots
  • +
+
latest-alpinelatest, but based on Alpine Linux. 🧪 Experimental
latest-plus-alpinelatest-plus, but based on Alpine Linux. 🧪 Experimental
+ +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 ``` diff --git a/bookmarks/frontend/behaviors/bookmark-details.js b/bookmarks/frontend/behaviors/bookmark-details.js deleted file mode 100644 index eccc8c3..0000000 --- a/bookmarks/frontend/behaviors/bookmark-details.js +++ /dev/null @@ -1,38 +0,0 @@ -import { registerBehavior } from "./index"; - -class BookmarkDetails { - constructor(element) { - this.form = element.querySelector(".status form"); - if (!this.form) { - // Form may not exist if user does not own the bookmark - return; - } - this.form.addEventListener("submit", (event) => { - event.preventDefault(); - this.submitForm(); - }); - - const inputs = this.form.querySelectorAll("input"); - inputs.forEach((input) => { - input.addEventListener("change", () => { - this.submitForm(); - }); - }); - } - - async submitForm() { - const url = this.form.action; - const formData = new FormData(this.form); - - await fetch(url, { - method: "POST", - body: formData, - redirect: "manual", // ignore redirect - }); - - // Refresh bookmark page if it exists - document.dispatchEvent(new CustomEvent("bookmark-page-refresh")); - } -} - -registerBehavior("ld-bookmark-details", BookmarkDetails); diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js index 89b1b76..e60d82e 100644 --- a/bookmarks/frontend/behaviors/bookmark-page.js +++ b/bookmarks/frontend/behaviors/bookmark-page.js @@ -1,4 +1,4 @@ -import { registerBehavior, swap } from "./index"; +import { registerBehavior, swapContent } from "./index"; class BookmarkPage { constructor(element) { @@ -37,8 +37,8 @@ class BookmarkPage { fetch(`${bookmarksUrl}${query}`).then((response) => response.text()), fetch(`${tagsUrl}${query}`).then((response) => response.text()), ]).then(([bookmarkListHtml, tagCloudHtml]) => { - swap(this.bookmarkList, bookmarkListHtml); - swap(this.tagCloud, tagCloudHtml); + swapContent(this.bookmarkList, bookmarkListHtml); + swapContent(this.tagCloud, tagCloudHtml); // Dispatch list updated event const listElement = this.bookmarkList.querySelector( diff --git a/bookmarks/frontend/behaviors/form.js b/bookmarks/frontend/behaviors/form.js new file mode 100644 index 0000000..5b78c31 --- /dev/null +++ b/bookmarks/frontend/behaviors/form.js @@ -0,0 +1,54 @@ +import { registerBehavior, swap } from "./index"; + +class FormBehavior { + constructor(element) { + this.element = element; + element.addEventListener("submit", this.onFormSubmit.bind(this)); + } + + async onFormSubmit(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 + }); + + // Dispatch refresh events + const refreshEvents = this.element.getAttribute("refresh-events"); + if (refreshEvents) { + refreshEvents.split(",").forEach((eventName) => { + document.dispatchEvent(new CustomEvent(eventName)); + }); + } + + // Refresh form + await this.refresh(); + } + + async refresh() { + const refreshUrl = this.element.getAttribute("refresh-url"); + const html = await fetch(refreshUrl).then((response) => response.text()); + swap(this.element, html); + } +} + +class FormAutoSubmitBehavior { + constructor(element) { + this.element = element; + this.element.addEventListener("change", () => { + const form = this.element.closest("form"); + form.dispatchEvent(new Event("submit", { cancelable: true })); + }); + } +} + +registerBehavior("ld-form", FormBehavior); +registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior); diff --git a/bookmarks/frontend/behaviors/index.js b/bookmarks/frontend/behaviors/index.js index 548edb6..d7b79e8 100644 --- a/bookmarks/frontend/behaviors/index.js +++ b/bookmarks/frontend/behaviors/index.js @@ -12,7 +12,14 @@ export function applyBehaviors(container, behaviorNames = null) { behaviorNames.forEach((behaviorName) => { const behavior = behaviorRegistry[behaviorName]; - const elements = container.querySelectorAll(`[${behaviorName}]`); + const elements = Array.from( + container.querySelectorAll(`[${behaviorName}]`), + ); + + // Include the container element if it has the behavior + if (container.hasAttribute && container.hasAttribute(behaviorName)) { + elements.push(container); + } elements.forEach((element) => { element.__behaviors = element.__behaviors || []; @@ -31,6 +38,13 @@ export function applyBehaviors(container, behaviorNames = null) { } export function swap(element, html) { + const dom = new DOMParser().parseFromString(html, "text/html"); + const newElement = dom.body.firstChild; + element.replaceWith(newElement); + applyBehaviors(newElement); +} + +export function swapContent(element, html) { element.innerHTML = html; applyBehaviors(element); } diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js index a6e3929..0ea20f0 100644 --- a/bookmarks/frontend/index.js +++ b/bookmarks/frontend/index.js @@ -1,8 +1,8 @@ -import "./behaviors/bookmark-details"; import "./behaviors/bookmark-page"; import "./behaviors/bulk-edit"; import "./behaviors/confirm-button"; import "./behaviors/dropdown"; +import "./behaviors/form"; import "./behaviors/modal"; import "./behaviors/global-shortcuts"; import "./behaviors/tag-autocomplete"; diff --git a/bookmarks/migrations/0030_bookmarkasset.py b/bookmarks/migrations/0030_bookmarkasset.py new file mode 100644 index 0000000..6cd8e15 --- /dev/null +++ b/bookmarks/migrations/0030_bookmarkasset.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.2 on 2024-03-31 08:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0029_bookmark_list_actions_toast"), + ] + + operations = [ + migrations.CreateModel( + name="BookmarkAsset", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date_created", models.DateTimeField(auto_now_add=True)), + ("file", models.CharField(blank=True, max_length=2048)), + ("file_size", models.IntegerField(null=True)), + ("asset_type", models.CharField(max_length=64)), + ("content_type", models.CharField(max_length=128)), + ("display_name", models.CharField(blank=True, max_length=2048)), + ("status", models.CharField(max_length=64)), + ("gzip", models.BooleanField(default=False)), + ( + "bookmark", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="bookmarks.bookmark", + ), + ), + ], + ), + ] diff --git a/bookmarks/migrations/0031_userprofile_enable_automatic_html_snapshots.py b/bookmarks/migrations/0031_userprofile_enable_automatic_html_snapshots.py new file mode 100644 index 0000000..601a545 --- /dev/null +++ b/bookmarks/migrations/0031_userprofile_enable_automatic_html_snapshots.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-04-01 10:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0030_bookmarkasset"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="enable_automatic_html_snapshots", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookmarks/migrations/0032_html_snapshots_hint_toast.py b/bookmarks/migrations/0032_html_snapshots_hint_toast.py new file mode 100644 index 0000000..546ac51 --- /dev/null +++ b/bookmarks/migrations/0032_html_snapshots_hint_toast.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.2 on 2024-04-01 12:17 + +from django.db import migrations +from django.contrib.auth import get_user_model + +from bookmarks.models import Toast + +User = get_user_model() + + +def forwards(apps, schema_editor): + + for user in User.objects.all(): + toast = Toast( + key="html_snapshots_hint", + message="This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.", + owner=user, + ) + toast.save() + + +def reverse(apps, schema_editor): + Toast.objects.filter(key="bookmark_list_actions_hint").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"), + ] + + operations = [ + migrations.RunPython(forwards, reverse), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index c321dad..c27ae58 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -1,18 +1,22 @@ -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.db import models -from django.db.models.signals import post_save +from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.http import QueryDict from bookmarks.utils import unique from bookmarks.validators import BookmarkURLValidator +logger = logging.getLogger(__name__) + class Tag(models.Model): name = models.CharField(max_length=64) @@ -85,6 +89,47 @@ class Bookmark(models.Model): return self.resolved_title + " (" + self.url[:30] + "...)" +class BookmarkAsset(models.Model): + TYPE_SNAPSHOT = "snapshot" + + CONTENT_TYPE_HTML = "text/html" + + STATUS_PENDING = "pending" + STATUS_COMPLETE = "complete" + STATUS_FAILURE = "failure" + + bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE) + date_created = models.DateTimeField(auto_now_add=True, null=False) + file = models.CharField(max_length=2048, blank=True, null=False) + file_size = models.IntegerField(null=True) + asset_type = models.CharField(max_length=64, blank=False, null=False) + content_type = models.CharField(max_length=128, blank=False, null=False) + display_name = models.CharField(max_length=2048, blank=True, null=False) + status = models.CharField(max_length=64, blank=False, null=False) + gzip = models.BooleanField(default=False, null=False) + + def save(self, *args, **kwargs): + if self.file: + try: + file_path = os.path.join(settings.LD_ASSET_FOLDER, self.file) + if os.path.isfile(file_path): + self.file_size = os.path.getsize(file_path) + except Exception: + pass + super().save(*args, **kwargs) + + +@receiver(post_delete, sender=BookmarkAsset) +def bookmark_asset_deleted(sender, instance, **kwargs): + if instance.file: + filepath = os.path.join(settings.LD_ASSET_FOLDER, instance.file) + if os.path.isfile(filepath): + try: + os.remove(filepath) + except Exception as error: + logger.error(f"Failed to delete asset file: {filepath}", exc_info=error) + + class BookmarkForm(forms.ModelForm): # Use URLField for URL url = forms.CharField(validators=[BookmarkURLValidator()]) @@ -353,6 +398,7 @@ class UserProfile(models.Model): permanent_notes = models.BooleanField(default=False, null=False) custom_css = models.TextField(blank=True, null=False) search_preferences = models.JSONField(default=dict, null=False) + enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) class UserProfileForm(forms.ModelForm): @@ -369,6 +415,7 @@ class UserProfileForm(forms.ModelForm): "enable_sharing", "enable_public_sharing", "enable_favicons", + "enable_automatic_html_snapshots", "display_url", "display_view_bookmark_action", "display_edit_bookmark_action", diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py index 62db657..3297693 100644 --- a/bookmarks/services/bookmarks.py +++ b/bookmarks/services/bookmarks.py @@ -34,6 +34,9 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): tasks.create_web_archive_snapshot(current_user, bookmark, False) # Load favicon tasks.load_favicon(current_user, bookmark) + # Create HTML snapshot + if current_user.profile.enable_automatic_html_snapshots: + tasks.create_html_snapshot(bookmark) return bookmark diff --git a/bookmarks/services/monolith.py b/bookmarks/services/monolith.py new file mode 100644 index 0000000..8fa528c --- /dev/null +++ b/bookmarks/services/monolith.py @@ -0,0 +1,32 @@ +import gzip +import shutil +import subprocess +import os + +from django.conf import settings + + +class MonolithError(Exception): + pass + + +# Monolith isn't used at the moment, as the local snapshot implementation +# switched to single-file after the prototype. Keeping this around in case +# it turns out to be useful in the future. +def create_snapshot(url: str, filepath: str): + monolith_path = settings.LD_MONOLITH_PATH + monolith_options = settings.LD_MONOLITH_OPTIONS + temp_filepath = filepath + ".tmp" + + try: + command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}" + subprocess.run(command, check=True, shell=True) + + with open(temp_filepath, "rb") as raw_file, gzip.open( + filepath, "wb" + ) as gz_file: + shutil.copyfileobj(raw_file, gz_file) + + os.remove(temp_filepath) + except subprocess.CalledProcessError as error: + raise MonolithError(f"Failed to create snapshot: {error.stderr}") diff --git a/bookmarks/services/singlefile.py b/bookmarks/services/singlefile.py new file mode 100644 index 0000000..2fbaecc --- /dev/null +++ b/bookmarks/services/singlefile.py @@ -0,0 +1,33 @@ +import gzip +import os +import shutil +import subprocess + +from django.conf import settings + + +class SingeFileError(Exception): + pass + + +def create_snapshot(url: str, filepath: str): + singlefile_path = settings.LD_SINGLEFILE_PATH + singlefile_options = settings.LD_SINGLEFILE_OPTIONS + temp_filepath = filepath + ".tmp" + + try: + command = f"{singlefile_path} '{url}' {singlefile_options} {temp_filepath}" + subprocess.run(command, check=True, shell=True) + + # single-file doesn't return exit codes apparently, so check if the file was created + if not os.path.exists(temp_filepath): + raise SingeFileError("Failed to create snapshot") + + with open(temp_filepath, "rb") as raw_file, gzip.open( + filepath, "wb" + ) as gz_file: + shutil.copyfileobj(raw_file, gz_file) + + os.remove(temp_filepath) + except subprocess.CalledProcessError as error: + raise SingeFileError(f"Failed to create snapshot: {error.stderr}") diff --git a/bookmarks/services/tasks.py b/bookmarks/services/tasks.py index cb02e64..e9f1357 100644 --- a/bookmarks/services/tasks.py +++ b/bookmarks/services/tasks.py @@ -1,4 +1,5 @@ import logging +import os import waybackpy from background_task import background @@ -7,10 +8,11 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import User from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound +from django.utils import timezone, formats import bookmarks.services.wayback -from bookmarks.models import Bookmark, UserProfile -from bookmarks.services import favicon_loader +from bookmarks.models import Bookmark, BookmarkAsset, UserProfile +from bookmarks.services import favicon_loader, singlefile from bookmarks.services.website_loader import DEFAULT_USER_AGENT logger = logging.getLogger(__name__) @@ -193,3 +195,64 @@ def _schedule_refresh_favicons_task(user_id: int): tasks.append(task) Task.objects.bulk_create(tasks) + + +def is_html_snapshot_feature_active() -> bool: + return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS + + +def create_html_snapshot(bookmark: Bookmark): + if not is_html_snapshot_feature_active(): + return + + timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT") + asset = BookmarkAsset( + bookmark=bookmark, + asset_type=BookmarkAsset.TYPE_SNAPSHOT, + content_type="text/html", + display_name=f"HTML snapshot from {timestamp}", + status=BookmarkAsset.STATUS_PENDING, + ) + asset.save() + _create_html_snapshot_task(asset.id) + + +def _generate_snapshot_filename(asset: BookmarkAsset) -> str: + def sanitize_char(char): + if char.isalnum() or char in ("-", "_", "."): + return char + else: + return "_" + + formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S") + sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url) + + return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz" + + +@background() +def _create_html_snapshot_task(asset_id: int): + try: + asset = BookmarkAsset.objects.get(id=asset_id) + except BookmarkAsset.DoesNotExist: + return + + logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}") + + try: + filename = _generate_snapshot_filename(asset) + filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) + singlefile.create_snapshot(asset.bookmark.url, filepath) + asset.status = BookmarkAsset.STATUS_COMPLETE + asset.file = filename + asset.gzip = True + logger.info( + f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}" + ) + except singlefile.SingeFileError as error: + asset.status = BookmarkAsset.STATUS_FAILURE + logger.error( + f"Failed to create HTML snapshot for bookmark. url={asset.bookmark.url}", + exc_info=error, + ) + asset.save() diff --git a/bookmarks/styles/bookmark-details.scss b/bookmarks/styles/bookmark-details.scss index 0ac6108..b51a59c 100644 --- a/bookmarks/styles/bookmark-details.scss +++ b/bookmarks/styles/bookmark-details.scss @@ -37,6 +37,53 @@ 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; + } + + .assets .asset-text .filesize { + color: $gray-color; + margin-left: $unit-2; + } + + .assets .asset-actions, .assets-actions { + display: flex; + gap: $unit-3; + 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; } @@ -46,7 +93,7 @@ gap: $unit-2; } - .status form .form-group, .status form .form-switch { + .status .form-group, .status .form-switch { margin: 0; } diff --git a/bookmarks/templates/bookmarks/details.html b/bookmarks/templates/bookmarks/details.html index 8af50d5..e3392e2 100644 --- a/bookmarks/templates/bookmarks/details.html +++ b/bookmarks/templates/bookmarks/details.html @@ -1,13 +1,13 @@ {% extends 'bookmarks/layout.html' %} {% block content %} -
- {% if request.user == bookmark.owner %} +
+ {% if details.is_editable %} {% include 'bookmarks/details/actions.html' %} {% endif %} {% include 'bookmarks/details/title.html' %}
- {% include 'bookmarks/details/content.html' %} + {% include 'bookmarks/details/form.html' %}
{% endblock %} diff --git a/bookmarks/templates/bookmarks/details/actions.html b/bookmarks/templates/bookmarks/details/actions.html index 0fc844f..a6e87e8 100644 --- a/bookmarks/templates/bookmarks/details/actions.html +++ b/bookmarks/templates/bookmarks/details/actions.html @@ -1,11 +1,14 @@
- Edit + Edit
-
+ {% csrf_token %} -
diff --git a/bookmarks/templates/bookmarks/details/asset_icon.html b/bookmarks/templates/bookmarks/details/asset_icon.html new file mode 100644 index 0000000..6853fba --- /dev/null +++ b/bookmarks/templates/bookmarks/details/asset_icon.html @@ -0,0 +1,42 @@ +{% if asset.content_type == 'text/html' %} + + + + + + + + + + + + +{% elif asset.content_type == 'application/pdf' %} + + + + + + + + + +{% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %} + + + + + + + +{% else %} + + + + + +{% endif %} \ No newline at end of file diff --git a/bookmarks/templates/bookmarks/details/assets.html b/bookmarks/templates/bookmarks/details/assets.html new file mode 100644 index 0000000..fde3c4f --- /dev/null +++ b/bookmarks/templates/bookmarks/details/assets.html @@ -0,0 +1,37 @@ +{% if details.assets %} +
+ {% for asset in details.assets %} +
+
+ {% include 'bookmarks/details/asset_icon.html' %} +
+
+ + {{ asset.display_name }} + {% if asset.status == 'pending' %}(queued){% endif %} + {% if asset.status == 'failure' %}(failed){% endif %} + + {% if asset.file_size %} + {{ asset.file_size|filesizeformat }} + {% endif %} +
+
+ {% if asset.file %} + View + {% endif %} + {% if details.is_editable %} + + {% endif %} +
+
+ {% endfor %} +
+{% endif %} + +{% if details.is_editable %} +
+ +
+{% endif %} \ No newline at end of file diff --git a/bookmarks/templates/bookmarks/details/content.html b/bookmarks/templates/bookmarks/details/content.html deleted file mode 100644 index ea4377e..0000000 --- a/bookmarks/templates/bookmarks/details/content.html +++ /dev/null @@ -1,85 +0,0 @@ -{% load static %} -{% load shared %} - - -
- {% if request.user == bookmark.owner %} -
-
Status
-
-
- {% csrf_token %} -
- -
-
- -
- {% if request.user_profile.enable_sharing %} -
- -
- {% endif %} -
-
-
- {% endif %} - {% if bookmark.tag_names %} -
-
Tags
-
- {% for tag_name in bookmark.tag_names %} - {{ tag_name|hash_tag }} - {% endfor %} -
-
- {% endif %} -
-
Date added
-
- {{ bookmark.date_added }} -
-
- {% if bookmark.resolved_description %} -
-
Description
-
{{ bookmark.resolved_description }}
-
- {% endif %} - {% if bookmark.notes %} -
-
Notes
-
{% markdown bookmark.notes %}
-
- {% endif %} -
diff --git a/bookmarks/templates/bookmarks/details/form.html b/bookmarks/templates/bookmarks/details/form.html new file mode 100644 index 0000000..f9c3006 --- /dev/null +++ b/bookmarks/templates/bookmarks/details/form.html @@ -0,0 +1,99 @@ +{% load static %} +{% load shared %} + +
+ +
+ {% if details.is_editable %} +
+
Status
+
+ {% csrf_token %} +
+ +
+
+ +
+ {% if details.profile.enable_sharing %} +
+ +
+ {% endif %} +
+
+ {% endif %} + {% if details.show_files %} +
+
Files
+
+ {% include 'bookmarks/details/assets.html' %} +
+
+ {% endif %} + {% if details.bookmark.tag_names %} +
+
Tags
+
+ {% for tag_name in details.bookmark.tag_names %} + {{ tag_name|hash_tag }} + {% endfor %} +
+
+ {% endif %} +
+
Date added
+
+ {{ details.bookmark.date_added }} +
+
+ {% if details.bookmark.resolved_description %} +
+
Description
+
{{ details.bookmark.resolved_description }}
+
+ {% endif %} + {% if details.bookmark.notes %} +
+
Notes
+
{% markdown details.bookmark.notes %}
+
+ {% endif %} +
+
diff --git a/bookmarks/templates/bookmarks/details/title.html b/bookmarks/templates/bookmarks/details/title.html index a5982a7..552e3c8 100644 --- a/bookmarks/templates/bookmarks/details/title.html +++ b/bookmarks/templates/bookmarks/details/title.html @@ -1,3 +1,3 @@

- {{ bookmark.resolved_title }} + {{ details.bookmark.resolved_title }}

diff --git a/bookmarks/templates/bookmarks/details_modal.html b/bookmarks/templates/bookmarks/details_modal.html index ec24bc5..844f318 100644 --- a/bookmarks/templates/bookmarks/details_modal.html +++ b/bookmarks/templates/bookmarks/details_modal.html @@ -1,4 +1,4 @@ -