forked from FrederikBaerentsen/BrickTracker
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bfbbbf298 | |||
| 79f348178c | |||
| 07be7b6004 | |||
| cb24cfc014 | |||
| 418bd5cd9d | |||
| 9953e3921a | |||
| 2d0fa7bf89 | |||
| c6b9f1c61a | |||
| e28ad8b32c | |||
| 6d70dbdf8b | |||
| 1dee03fbea | |||
| bb05fbdd22 | |||
| 3496143962 |
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding
|
||||
|
||||
## 1.2.2:
|
||||
|
||||
This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade.
|
||||
|
||||
## 1.2.1:
|
||||
|
||||
This release fixes a bug where you could not add a set if no metadata was configured.
|
||||
|
||||
## 1.2.0:
|
||||
|
||||
> **Warning**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
from shutil import copyfileobj
|
||||
import traceback
|
||||
from typing import Tuple, TYPE_CHECKING
|
||||
@@ -11,6 +12,8 @@ import humanize
|
||||
import requests
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
import re
|
||||
import cloudscraper
|
||||
|
||||
from .exceptions import ErrorException, DownloadException
|
||||
if TYPE_CHECKING:
|
||||
@@ -89,91 +92,72 @@ class BrickInstructions(object):
|
||||
|
||||
# Download an instruction file
|
||||
def download(self, path: str, /) -> None:
|
||||
"""
|
||||
Streams the PDF in chunks and uses self.socket.update_total
|
||||
+ self.socket.progress_count to drive a determinate bar.
|
||||
"""
|
||||
try:
|
||||
# Just to make sure that the progress is initiated
|
||||
self.socket.progress(
|
||||
message='Downloading {file}'.format(
|
||||
file=self.filename,
|
||||
)
|
||||
)
|
||||
|
||||
target = self.path(filename=secure_filename(self.filename))
|
||||
|
||||
# Skipping rather than failing here
|
||||
# Skip if we already have it
|
||||
if os.path.isfile(target):
|
||||
self.socket.complete(
|
||||
message='File {file} already exists, skipped'.format(
|
||||
file=self.filename,
|
||||
)
|
||||
return self.socket.complete(
|
||||
message=f"File {self.filename} already exists, skipped"
|
||||
)
|
||||
|
||||
else:
|
||||
url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501
|
||||
path=path
|
||||
)
|
||||
trimmed_url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501
|
||||
path=path.partition('/')[0]
|
||||
)
|
||||
# Fetch PDF via cloudscraper (to bypass Cloudflare)
|
||||
scraper = cloudscraper.create_scraper()
|
||||
scraper.headers.update({
|
||||
"User-Agent": current_app.config['REBRICKABLE_USER_AGENT']
|
||||
})
|
||||
resp = scraper.get(path, stream=True)
|
||||
if not resp.ok:
|
||||
raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
|
||||
|
||||
# Request the file
|
||||
self.socket.progress(
|
||||
message='Requesting {url}'.format(
|
||||
url=trimmed_url,
|
||||
)
|
||||
)
|
||||
# Tell the socket how many bytes in total
|
||||
total = int(resp.headers.get("Content-Length", 0))
|
||||
self.socket.update_total(total)
|
||||
|
||||
response = requests.get(url, stream=True)
|
||||
if response.ok:
|
||||
# Reset the counter and kick off at 0%
|
||||
self.socket.progress_count = 0
|
||||
self.socket.progress(message=f"Starting download {self.filename}")
|
||||
|
||||
# Store the content header as size
|
||||
try:
|
||||
self.size = int(
|
||||
response.headers.get('Content-length', 0)
|
||||
)
|
||||
except Exception:
|
||||
self.size = 0
|
||||
# Write out in 8 KiB chunks and update the counter
|
||||
with open(target, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue
|
||||
f.write(chunk)
|
||||
|
||||
# Downloading the file
|
||||
# Bump the internal counter and emit
|
||||
self.socket.progress_count += len(chunk)
|
||||
self.socket.progress(
|
||||
message='Downloading {url} ({size})'.format(
|
||||
url=trimmed_url,
|
||||
size=self.human_size(),
|
||||
message=(
|
||||
f"Downloading {self.filename} "
|
||||
f"({humanize.naturalsize(self.socket.progress_count)}/"
|
||||
f"{humanize.naturalsize(self.socket.progress_total)})"
|
||||
)
|
||||
)
|
||||
|
||||
with open(target, 'wb') as f:
|
||||
copyfileobj(response.raw, f)
|
||||
else:
|
||||
raise DownloadException('failed to download: {code}'.format( # noqa: E501
|
||||
code=response.status_code
|
||||
))
|
||||
|
||||
# Info
|
||||
logger.info('The instruction file {file} has been downloaded'.format( # noqa: E501
|
||||
file=self.filename
|
||||
))
|
||||
|
||||
# Complete
|
||||
self.socket.complete(
|
||||
message='File {file} downloaded ({size})'.format( # noqa: E501
|
||||
file=self.filename,
|
||||
size=self.human_size()
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.socket.fail(
|
||||
message='Error while downloading instruction {file}: {error}'.format( # noqa: E501
|
||||
file=self.filename,
|
||||
error=e,
|
||||
)
|
||||
# Done!
|
||||
logger.info(f"Downloaded {self.filename}")
|
||||
self.socket.complete(
|
||||
message=f"File {self.filename} downloaded ({self.human_size()})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(traceback.format_exc())
|
||||
self.socket.fail(
|
||||
message=f"Error downloading {self.filename}: {e}"
|
||||
)
|
||||
|
||||
# Display the size in a human format
|
||||
def human_size(self) -> str:
|
||||
return humanize.naturalsize(self.size)
|
||||
try:
|
||||
size = self.size
|
||||
except AttributeError:
|
||||
size = os.path.getsize(self.path())
|
||||
return humanize.naturalsize(size)
|
||||
|
||||
# Display the time in a human format
|
||||
def human_time(self) -> str:
|
||||
@@ -250,40 +234,52 @@ class BrickInstructions(object):
|
||||
# Find the instructions for a set
|
||||
@staticmethod
|
||||
def find_instructions(set: str, /) -> list[Tuple[str, str]]:
|
||||
response = requests.get(
|
||||
current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format(
|
||||
path=set,
|
||||
),
|
||||
headers={
|
||||
'User-Agent': current_app.config['REBRICKABLE_USER_AGENT']
|
||||
}
|
||||
)
|
||||
"""
|
||||
Scrape Rebrickable’s HTML and return a list of
|
||||
(filename_slug, download_url). Duplicate slugs get _1, _2, …
|
||||
"""
|
||||
page_url = f"https://rebrickable.com/instructions/{set}/"
|
||||
logger.debug(f"[find_instructions] fetching HTML from {page_url!r}")
|
||||
|
||||
if not response.ok:
|
||||
raise ErrorException('Failed to load the Rebrickable instructions page. Status code: {code}'.format( # noqa: E501
|
||||
code=response.status_code
|
||||
))
|
||||
# Solve Cloudflare’s challenge
|
||||
scraper = cloudscraper.create_scraper()
|
||||
scraper.headers.update({'User-Agent': current_app.config['REBRICKABLE_USER_AGENT']})
|
||||
resp = scraper.get(page_url)
|
||||
if not resp.ok:
|
||||
raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}')
|
||||
|
||||
# Parse the HTML content
|
||||
soup = BeautifulSoup(response.content, 'html.parser')
|
||||
soup = BeautifulSoup(resp.content, 'html.parser')
|
||||
link_re = re.compile(r'^/instructions/\d+/.+/download/')
|
||||
|
||||
# Collect all <img> tags with "LEGO Building Instructions" in the
|
||||
# alt attribute
|
||||
found_tags: list[Tuple[str, str]] = []
|
||||
for a_tag in soup.find_all('a', href=True):
|
||||
img_tag = a_tag.find('img', alt=True)
|
||||
if img_tag and "LEGO Building Instructions" in img_tag['alt']:
|
||||
found_tags.append(
|
||||
(
|
||||
img_tag['alt'].removeprefix('LEGO Building Instructions for '), # noqa: E501
|
||||
a_tag['href']
|
||||
)
|
||||
) # Save alt and href
|
||||
raw: list[tuple[str, str]] = []
|
||||
for a in soup.find_all('a', href=link_re):
|
||||
img = a.find('img', alt=True)
|
||||
if not img or set not in img['alt']:
|
||||
continue
|
||||
|
||||
# Raise an error if nothing found
|
||||
if not len(found_tags):
|
||||
raise ErrorException('No instruction found for set {set}'.format(
|
||||
set=set
|
||||
))
|
||||
# Turn the alt text into a slug
|
||||
alt_text = img['alt'].removeprefix('LEGO Building Instructions for ')
|
||||
slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-')
|
||||
|
||||
return found_tags
|
||||
# Build the absolute download URL
|
||||
download_url = urljoin('https://rebrickable.com', a['href'])
|
||||
raw.append((slug, download_url))
|
||||
|
||||
if not raw:
|
||||
raise ErrorException(f'No download links found on instructions page for {set}')
|
||||
|
||||
# Disambiguate duplicate slugs by appending _1, _2, …
|
||||
from collections import Counter, defaultdict
|
||||
counts = Counter(name for name, _ in raw)
|
||||
seen: dict[str, int] = defaultdict(int)
|
||||
unique: list[tuple[str, str]] = []
|
||||
for name, url in raw:
|
||||
idx = seen[name]
|
||||
if counts[name] > 1 and idx > 0:
|
||||
final_name = f"{name}_{idx}"
|
||||
else:
|
||||
final_name = name
|
||||
seen[name] += 1
|
||||
unique.append((final_name, url))
|
||||
|
||||
return unique
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List, overload, Self, Type, TypeVar
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from .exceptions import NotFoundException
|
||||
from .exceptions import ErrorException, NotFoundException
|
||||
from .fields import BrickRecordFields
|
||||
from .record_list import BrickRecordList
|
||||
from .set_owner import BrickSetOwner
|
||||
@@ -113,12 +113,17 @@ class BrickMetadataList(BrickRecordList[T]):
|
||||
|
||||
# Grab a specific status
|
||||
@classmethod
|
||||
def get(cls, id: str, /, *, allow_none: bool = False) -> T:
|
||||
def get(cls, id: str | None, /, *, allow_none: bool = False) -> T:
|
||||
new = cls.new()
|
||||
|
||||
if allow_none and id == '':
|
||||
if allow_none and (id == '' or id is None):
|
||||
return new.model()
|
||||
|
||||
if id is None:
|
||||
raise ErrorException('Cannot get {kind} with no ID'.format(
|
||||
kind=new.kind.capitalize()
|
||||
))
|
||||
|
||||
if id not in new.mapping:
|
||||
raise NotFoundException(
|
||||
'{kind} with ID {id} was not found in the database'.format(
|
||||
|
||||
@@ -4,6 +4,19 @@ PRAGMA foreign_keys = ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Fix: somehow a deletion bug was introduced in an older release?
|
||||
DELETE FROM "inventory"
|
||||
WHERE "inventory"."u_id" NOT IN (
|
||||
SELECT "bricktracker_sets"."id"
|
||||
FROM "bricktracker_sets"
|
||||
);
|
||||
|
||||
DELETE FROM "missing"
|
||||
WHERE "missing"."u_id" NOT IN (
|
||||
SELECT "bricktracker_sets"."id"
|
||||
FROM "bricktracker_sets"
|
||||
);
|
||||
|
||||
-- Create a Bricktracker parts table: an amount of parts linked to a Bricktracker set
|
||||
CREATE TABLE "bricktracker_parts" (
|
||||
"id" TEXT NOT NULL,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[str] = '1.2.0'
|
||||
__version__: Final[str] = '1.2.2'
|
||||
__database_version__: Final[int] = 15
|
||||
|
||||
@@ -84,6 +84,7 @@ def admin() -> str:
|
||||
open_image = request.args.get('open_image', None)
|
||||
open_instructions = request.args.get('open_instructions', None)
|
||||
open_logout = request.args.get('open_logout', None)
|
||||
open_metadata = request.args.get('open_metadata', None)
|
||||
open_owner = request.args.get('open_owner', None)
|
||||
open_purchase_location = request.args.get('open_purchase_location', None)
|
||||
open_retired = request.args.get('open_retired', None)
|
||||
@@ -93,6 +94,7 @@ def admin() -> str:
|
||||
open_theme = request.args.get('open_theme', None)
|
||||
|
||||
open_metadata = (
|
||||
open_metadata or
|
||||
open_owner or
|
||||
open_purchase_location or
|
||||
open_status or
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ services:
|
||||
bricktracker:
|
||||
container_name: BrickTracker
|
||||
restart: unless-stopped
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.2
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ services:
|
||||
bricktracker:
|
||||
container_name: BrickTracker
|
||||
restart: unless-stopped
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.2
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
|
||||
+4
-4
@@ -61,9 +61,9 @@ docker compose up -d
|
||||
|
||||
2. Access BrickTracker at `http://localhost:3333`
|
||||
|
||||
Please refer to [Environment Variables Reference](docs/env.md) for a list of available variables.
|
||||
Please refer to [Environment Variables Reference](env.md) for a list of available variables.
|
||||
|
||||
3. Read more in [First steps](docs/first-steps.md)
|
||||
3. Read more in [First steps](first-steps.md)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -85,6 +85,6 @@ Please refer to [Environment Variables Reference](docs/env.md) for a list of ava
|
||||
- Check for any syntax errors in `.env` file
|
||||
- Verify no conflicting environment variables are set in the shell
|
||||
|
||||
For more troubleshooting, take a look at [Common Errors](docs/common-errors.md)
|
||||
For more troubleshooting, take a look at [Common Errors](common-errors.md)
|
||||
|
||||
Please refer to [Setup](docs/setup.md) for more information.
|
||||
Please refer to [Setup](setup.md) for more information.
|
||||
+1
-1
@@ -53,7 +53,7 @@ services:
|
||||
The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it.
|
||||
You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch.
|
||||
|
||||
[Environment Variables Reference](docs/env.md) contains a table of the available variables.
|
||||
[Environment Variables Reference](env.md) contains a table of the available variables.
|
||||
|
||||
## Database file
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ rebrick
|
||||
requests
|
||||
tzdata
|
||||
bs4
|
||||
cloudscraper
|
||||
|
||||
+59
-52
@@ -37,59 +37,66 @@
|
||||
</div>
|
||||
<h6 class="border-bottom mt-2">Metadata</h6>
|
||||
<div class="accordion accordion" id="metadata">
|
||||
{% if brickset_owners | length %}
|
||||
{{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
|
||||
<div id="add-owners">
|
||||
{% for owner in brickset_owners %}
|
||||
{% with id=owner.as_dataset() %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
|
||||
<label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% if not (brickset_owners | length) and not (brickset_purchase_locations | length) and not (brickset_storages | length) and not (brickset_tags | length) %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
You have no metadata configured.
|
||||
You can add entries in the <a href="{{ url_for('admin.admin', open_metadata=true) }}" class="btn btn-warning" role="button"><i class="ri-profile-line"></i> Set metadata management</a> section of the Admin panel.
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% if brickset_purchase_locations | length %}
|
||||
{{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }}
|
||||
<label class="visually-hidden" for="add-purchase-location">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
<select id="add-purchase-location" class="form-select" autocomplete="off">
|
||||
<option value="" selected><i>None</i></option>
|
||||
{% for purchase_location in brickset_purchase_locations %}
|
||||
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% if brickset_storages | length %}
|
||||
{{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
|
||||
<label class="visually-hidden" for="add-storage">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
<select id="add-storage" class="form-select" autocomplete="off">
|
||||
<option value="" selected><i>None</i></option>
|
||||
{% for storage in brickset_storages %}
|
||||
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% if brickset_tags | length %}
|
||||
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
|
||||
<div id="add-tags">
|
||||
{% for tag in brickset_tags %}
|
||||
{% with id=tag.as_dataset() %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
|
||||
<label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% else %}
|
||||
{% if brickset_owners | length %}
|
||||
{{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
|
||||
<div id="add-owners">
|
||||
{% for owner in brickset_owners %}
|
||||
{% with id=owner.as_dataset() %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
|
||||
<label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% if brickset_purchase_locations | length %}
|
||||
{{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }}
|
||||
<label class="visually-hidden" for="add-purchase-location">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
<select id="add-purchase-location" class="form-select" autocomplete="off">
|
||||
<option value="" selected><i>None</i></option>
|
||||
{% for purchase_location in brickset_purchase_locations %}
|
||||
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% if brickset_storages | length %}
|
||||
{{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
|
||||
<label class="visually-hidden" for="add-storage">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
<select id="add-storage" class="form-select" autocomplete="off">
|
||||
<option value="" selected><i>None</i></option>
|
||||
{% for storage in brickset_storages %}
|
||||
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% if brickset_tags | length %}
|
||||
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
|
||||
<div id="add-tags">
|
||||
{% for tag in brickset_tags %}
|
||||
{% with id=tag.as_dataset() %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
|
||||
<label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
{% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %}
|
||||
{% if g.login.is_authenticated() %}
|
||||
<input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
|
||||
<input class="form-check-input px-1" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
|
||||
{% if not delete %}
|
||||
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" {% if parent %}data-changer-parent="{{ parent }}"{% endif %}
|
||||
{% else %}
|
||||
@@ -22,8 +22,8 @@
|
||||
{% else %}
|
||||
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
{% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
|
||||
<input class="form-control form-control-sm flex-shrink-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
|
||||
{% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
|
||||
{% if g.login.is_authenticated() %}
|
||||
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
|
||||
{% if date %}data-changer-date="true"{% endif %}
|
||||
@@ -31,12 +31,12 @@
|
||||
disabled
|
||||
{% endif %}
|
||||
autocomplete="off">
|
||||
{% if suffix %}<span class="input-group-text d-none d-md-inline">{{ suffix }}</span>{% endif %}
|
||||
{% if suffix %}<span class="input-group-text d-none d-md-inline px-1">{{ suffix }}</span>{% endif %}
|
||||
{% if g.login.is_authenticated() %}
|
||||
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span>
|
||||
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
|
||||
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></span>
|
||||
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
|
||||
{% else %}
|
||||
<span class="input-group-text ri-prohibited-line"></span>
|
||||
<span class="input-group-text ri-prohibited-line px-1"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -46,8 +46,8 @@
|
||||
{% if g.login.is_authenticated() %}
|
||||
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
{% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
|
||||
<select id="{{ prefix }}-{{ id }}" class="form-select"
|
||||
{% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
|
||||
<select id="{{ prefix }}-{{ id }}" class="form-select px-1"
|
||||
{% if not delete %}
|
||||
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
|
||||
{% else %}
|
||||
@@ -59,8 +59,8 @@
|
||||
<option value="{{ metadata.fields.id }}" {% if metadata.fields.id == value %}selected{% endif %}>{{ metadata.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span>
|
||||
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
|
||||
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></span>
|
||||
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
<label class="visually-hidden" for="grid-search">Search</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
|
||||
<input id="grid-search" data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, purchase location, storage, tag" value="">
|
||||
<input id="grid-search" data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm px-1" type="text" placeholder="Set, name, number of parts, theme, year, owner, purchase location, storage, tag" value="">
|
||||
<button id="grid-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user