UI - Adding thumbnails to lister page

This commit is contained in:
dgtlmoon
2025-05-14 10:32:45 +02:00
parent c162ec9d52
commit ccd9419c88
7 changed files with 162 additions and 6 deletions

View File

@@ -122,7 +122,11 @@
{% set mute_label = 'UnMute notification' if watch.notification_muted else 'Mute notification' %}
<a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ mute_label }}" title="{{ mute_label }}" class="icon icon-mute" ></a>
</td>
<td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
<td class="title-col inline">
{% if watch.get_screenshot() %}
<img class="thumbnail" src="{{url_for('static_content', group='thumbnail', filename=watch.uuid)}}" alt="thumbnail screenshot" title="thumbnail screenshot" >
{% endif %}
<span>{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}</span>
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
@@ -137,13 +141,11 @@
{% if watch.has_browser_steps %}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" title="Browser Steps is enabled" >{% endif %}
{% if watch.last_error is defined and watch.last_error != False %}
<div class="fetch-error">{{ watch.last_error }}
{% if '403' in watch.last_error %}
{% if has_proxies %}
<a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try other proxies/location</a>&nbsp;
{% endif %}
<a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a>
{% endif %}
{% if 'empty result or contain only an image' in watch.last_error %}
<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Detecting-changes-in-images">more help here</a>.
@@ -166,7 +168,7 @@
<span class="watch-tag-list">{{ watch_tag.title }}</span>
{% endfor %}
</td>
<!-- @todo make it so any watch handler obj can expose this --->
{% if any_has_restock_price_processor %}
<td class="restock-and-price">
{% if watch['processor'] == 'restock_diff' %}

View File

@@ -1,5 +1,3 @@
from flask import Blueprint
from json_logic.builtins import BUILTINS
from .exceptions import EmptyConditionRuleRowNotUsable

View File

@@ -378,6 +378,42 @@ def changedetection_app(config=None, datastore_o=None):
except FileNotFoundError:
abort(404)
if group == 'thumbnail':
# Could be sensitive, follow password requirements
if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
abort(403)
# Get the watch object
watch = datastore.data['watching'].get(filename)
if not watch:
abort(404)
# Generate thumbnail if needed
max_age = int(request.args.get('max_age', '3200'))
thumbnail_path = watch.get_screenshot_as_thumbnail(max_age=max_age)
if not thumbnail_path:
abort(404)
try:
# Get file modification time for ETag
file_mtime = int(os.path.getmtime(thumbnail_path))
etag = f'"{file_mtime}"'
# Check if browser has valid cached version
if request.if_none_match and etag in request.if_none_match:
return "", 304 # Not Modified
# Set up response with appropriate cache headers
response = make_response(send_from_directory(os.path.dirname(thumbnail_path), os.path.basename(thumbnail_path)))
response.headers['Content-type'] = 'image/jpeg'
response.headers['ETag'] = etag
response.headers['Cache-Control'] = 'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
return response
except FileNotFoundError:
abort(404)
if group == 'visual_selector_data':
# Could be sensitive, follow password requirements

View File

@@ -401,6 +401,70 @@ class model(watch_base):
# False is not an option for AppRise, must be type None
return None
def get_screenshot_as_thumbnail(self, max_age=3200):
"""Return path to a square thumbnail of the most recent screenshot.
Creates a 150x150 pixel thumbnail from the top portion of the screenshot.
Args:
max_age: Maximum age in seconds before recreating thumbnail
Returns:
Path to thumbnail or None if no screenshot exists
"""
import os
import time
thumbnail_path = os.path.join(self.watch_data_dir, "thumbnail.jpeg")
top_trim = 500 # Pixels from top of screenshot to use
screenshot_path = self.get_screenshot()
if not screenshot_path:
return None
# Reuse thumbnail if it's fresh and screenshot hasn't changed
if os.path.isfile(thumbnail_path):
thumbnail_mtime = os.path.getmtime(thumbnail_path)
screenshot_mtime = os.path.getmtime(screenshot_path)
if screenshot_mtime <= thumbnail_mtime and time.time() - thumbnail_mtime < max_age:
return thumbnail_path
try:
from PIL import Image
with Image.open(screenshot_path) as img:
# Crop top portion first (full width, top_trim height)
top_crop_height = min(top_trim, img.height)
img = img.crop((0, 0, img.width, top_crop_height))
# Create a smaller intermediate image (to reduce memory usage)
aspect = img.width / img.height
interim_width = min(top_trim, img.width)
interim_height = int(interim_width / aspect) if aspect > 0 else top_trim
img = img.resize((interim_width, interim_height), Image.NEAREST)
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
# Crop to square from top center
square_size = min(img.width, img.height)
left = (img.width - square_size) // 2
img = img.crop((left, 0, left + square_size, square_size))
# Final resize to exact thumbnail size with better filter
img = img.resize((150, 150), Image.BILINEAR)
# Save with optimized settings
img.save(thumbnail_path, "JPEG", quality=75, optimize=True)
return thumbnail_path
except Exception as e:
logger.error(f"Error creating thumbnail for {self.get('uuid')}: {str(e)}")
return None
def __get_file_ctime(self, filename):
fname = os.path.join(self.watch_data_dir, filename)
if os.path.isfile(fname):

View File

@@ -0,0 +1,30 @@
.watch-table {
td,
th {
vertical-align: middle;
}
td.inline.title-col {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
* {
display: inline-block;
}
img.thumbnail {
width: 32px;
object-fit: cover; /* crop/fill if needed */
border-radius: 8px; /* subtle rounded corners */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); /* soft shadow */
border: 1px solid #ddd; /* light border for contrast */
filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));
background-color: #fff; /* fallback bg for SVGs without bg */
}
}
}

View File

@@ -15,6 +15,7 @@
@import "parts/preview_text_filter";
@import "parts/_edit";
@import "parts/_conditions_table";
@import "parts/_lister_extra";
body {
color: var(--color-text);

View File

@@ -623,6 +623,31 @@ ul#conditions_match_logic {
.fieldlist_formfields .addRuleRow:hover, .fieldlist_formfields .removeRuleRow:hover, .fieldlist_formfields .verifyRuleRow:hover {
background-color: #999; }
.watch-table td,
.watch-table th {
vertical-align: middle; }
.watch-table td.inline.title-col {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap; }
.watch-table td.inline.title-col * {
display: inline-block; }
.watch-table td.inline.title-col img.thumbnail {
width: 32px;
object-fit: cover;
/* crop/fill if needed */
border-radius: 8px;
/* subtle rounded corners */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
/* soft shadow */
border: 1px solid #ddd;
/* light border for contrast */
filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));
background-color: #fff;
/* fallback bg for SVGs without bg */ }
body {
color: var(--color-text);
background: var(--color-background-page);