43 Commits

Author SHA1 Message Date
gru
e22c7e7dd2 Update deploy/varnish/default.vcl.template 2026-02-25 23:03:23 +01:00
gru
3cbeab37fb Update deploy/varnish/default.vcl.template 2026-02-25 23:00:40 +01:00
gru
3f26f5452f Update deploy/varnish/default.vcl.template 2026-02-25 22:58:34 +01:00
gru
98a52f3c25 Update deploy/varnish/default.vcl.template 2026-02-25 15:14:42 +01:00
gru
1705320ada Update deploy/varnish/default.vcl.template 2026-02-25 15:13:59 +01:00
gru
9fab8046f6 Update deploy/varnish/default.vcl.template 2026-02-25 15:10:48 +01:00
gru
eec49e2bd5 Update deploy/varnish/default.vcl.template 2026-02-25 15:07:36 +01:00
gru
cfc644e612 Update deploy/varnish/default.vcl.template 2026-02-25 15:02:50 +01:00
gru
ec67dacbbc Update app.py 2026-02-25 14:55:23 +01:00
gru
af9cef7b5b Update .env.example 2026-02-25 14:48:09 +01:00
gru
3a6ad5fd73 Update app.py 2026-02-25 14:21:47 +01:00
gru
bb3c9680a8 Update app.py 2026-02-25 14:19:57 +01:00
gru
1be0c7b9fc Update app.py 2026-02-25 14:16:31 +01:00
gru
8b5c843371 Update app.py 2026-02-25 14:10:26 +01:00
gru
b0a57b72e0 Update app.py 2026-02-25 14:06:06 +01:00
gru
8a462f6610 Update app.py 2026-02-25 14:03:00 +01:00
gru
f042653b86 Update app.py 2026-02-25 13:55:38 +01:00
gru
3cb08ad968 Update deploy/varnish/default.vcl.template 2026-02-25 13:54:33 +01:00
gru
c8b8d70c81 Update requirements.txt 2026-02-25 13:54:06 +01:00
gru
1d6bec5b8b Update docker-compose.yml 2026-02-25 00:29:08 +01:00
gru
1c623d49e3 Update docker-compose.yml 2026-02-25 00:23:21 +01:00
gru
8f08bf740a Update docker-compose.yml 2026-02-25 00:19:15 +01:00
gru
e8c6119def Update deploy/varnish/default.vcl.template 2026-02-25 00:11:05 +01:00
Mateusz Gruszczyński
4d5242a479 fix flask session socktio after flask-session upgrade 2026-02-20 23:57:37 +01:00
gru
4e1b200ab3 Update deploy/varnish/default.vcl.template 2026-02-20 23:48:59 +01:00
gru
859feba09e Update deploy/varnish/default.vcl.template 2026-02-20 23:44:19 +01:00
gru
8f0caf6c98 Update deploy/varnish/default.vcl.template 2026-02-20 23:42:04 +01:00
gru
95e3af4f76 Update deploy/varnish/default.vcl.template 2026-02-19 16:24:52 +01:00
gru
cf28a311ed Update deploy/varnish/default.vcl.template 2026-02-19 16:22:31 +01:00
gru
bbe8c559eb Update deploy/varnish/default.vcl.template 2026-02-19 16:19:27 +01:00
gru
28afbb4279 Update deploy/varnish/default.vcl.template 2026-02-19 16:16:00 +01:00
gru
fd7ca2fe6e Update README.md 2026-02-02 09:23:14 +01:00
gru
99ccd937a4 Update templates/base.html 2026-02-02 09:21:59 +01:00
Mateusz Gruszczyński
d5a2d1b309 kropka kategorii na malych ekranach 2026-01-21 11:15:04 +01:00
Mateusz Gruszczyński
34cfde795a kropka kategorii na malych ekranach 2026-01-21 11:11:22 +01:00
Mateusz Gruszczyński
43b5312e35 kropka kategorii na malych ekranach 2026-01-21 11:00:45 +01:00
Mateusz Gruszczyński
af40974018 kropka kategorii na malych ekranach 2026-01-21 10:58:01 +01:00
Mateusz Gruszczyński
a4d17492d2 kropka kategorii na malych ekranach 2026-01-21 10:55:50 +01:00
Mateusz Gruszczyński
a4403a0d33 poprawka dla malych ekranow 2026-01-13 11:25:55 +01:00
Mateusz Gruszczyński
218191a718 poprawka dla malych ekranow 2026-01-13 10:24:16 +01:00
Mateusz Gruszczyński
721387c994 poprawka dla malych ekranow 2026-01-13 09:23:39 +01:00
Mateusz Gruszczyński
3901cc152e poprawka dla malych ekranow 2026-01-13 09:03:05 +01:00
Mateusz Gruszczyński
177fde9e4b poprawka dla malych ekranow 2026-01-13 08:51:52 +01:00
9 changed files with 339 additions and 89 deletions

View File

@@ -153,12 +153,12 @@ LIB_JS_CACHE_CONTROL="max-age=86400"
# LIB_CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek CSS (/static/lib/css/)
# Domyślnie: "max-age=86400"
LIB_CSS_CACHE_CONTROL="max-age=86400"
LIB_CSS_CACHE_CONTROL="max-age=3600"
# UPLOADS_CACHE_CONTROL:
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
# Domyślnie: "max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="max-age=3600, immutable"
# DEFAULT_CATEGORIES:
# Lista domyślnych kategorii tworzonych automatycznie przy starcie aplikacji,

View File

@@ -21,7 +21,7 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik
1. Sklonuj repozytorium:
```bash
git https://gitea.linuxiarz.pl/gru/lista_zakupowa_live.git
git https://git.linuxiarz.pl/gru/lista_zakupowa_live.git
cd lista_zakupowa_live
```

165
app.py
View File

@@ -164,7 +164,15 @@ APP_VERSION = commit
app.config["APP_VERSION"] = APP_VERSION
db = SQLAlchemy(app)
socketio = SocketIO(app, async_mode="eventlet")
# old
#socketio = SocketIO(app, async_mode="eventlet")
# new flask
#socketio = SocketIO(app, async_mode="eventlet", manage_session=False)
# gevent
socketio = SocketIO(app, async_mode='gevent')
login_manager = LoginManager(app)
login_manager.login_view = "login"
@@ -1562,7 +1570,8 @@ def apply_headers(response):
# --- statyczne pliki (nagłówki z .env) ---
if request.path.startswith(("/static/", "/uploads/")):
response.headers["Vary"] = "Accept-Encoding"
response.headers.pop('Vary', None) # fix bug with backslash
response.headers['Vary'] = 'Accept-Encoding'
return response
# --- healthcheck ---
@@ -4203,6 +4212,158 @@ def robots_txt():
return content, 200, {"Content-Type": "text/plain"}
from flask import render_template_string
@app.route('/admin/debug-socket')
@login_required
@admin_required
def debug_socket():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Socket Debug</title>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<style>
body { font-family: monospace; background: #1e1e1e; color: #fff; padding: 20px; }
#log { height: 400px; overflow-y: scroll; background: #2d2d2d; padding: 15px; border-radius: 8px; margin: 10px 0; white-space: pre-wrap; }
button { background: #007bff; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 5px; cursor: pointer; }
button:hover { background: #0056b3; }
.status { font-size: 18px; font-weight: bold; margin: 10px 0; }
.connected { color: #28a745; }
.disconnected { color: #dc3545; }
</style>
</head>
<body>
<h1>Socket.IO Debug Tool</h1>
<div id="status" class="status disconnected">Rozlaczony</div>
<div id="info">
Transport: <span id="transport">-</span> |
Ping: <span id="ping">-</span>ms |
SID: <span id="sid">-</span>
</div>
<button onclick="connect()">Polacz</button>
<button onclick="disconnect()">Rozlacz</button>
<button onclick="emitTest()">Emit Test</button>
<button onclick="forcePolling()">Force Polling</button>
<h3>Logi:</h3>
<div id="log"></div>
<script>
let socket;
let logLines = 0;
let isPollingOnly = true;
function log(msg, color = '#fff') {
const logEl = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logEl.innerHTML += `[${time}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
logLines++;
if (logLines > 200) {
const lines = logEl.innerHTML.split('\\n');
logEl.innerHTML = lines.slice(-200).join('\\n');
logLines = 200;
}
}
function updateStatus(connected) {
const status = document.getElementById('status');
status.textContent = connected ? 'Polaczony' : 'Rozlaczony';
status.className = `status ${connected ? 'connected' : 'disconnected'}`;
}
function connect() {
if (socket) {
socket.disconnect();
socket = null;
}
const transports = isPollingOnly ? ['polling'] : ['polling', 'websocket'];
log(`Polaczenie z: ${transports.join(', ')}`);
socket = io('', {
transports: transports,
timeout: 20000,
autoConnect: false,
forceNew: true
});
socket.on('connect', function() {
log('CONNECTED OK');
updateStatus(true);
try {
const transport = socket.io.engine.transport.name;
document.getElementById('transport').textContent = transport;
document.getElementById('sid').textContent = socket.id.substring(0,8) + '...';
} catch(e) {
log('Transport info error: ' + e.message);
}
socket.emit('requestfulllist', {listid: 1});
});
socket.on('disconnect', function(reason) {
log('DISCONNECTED: ' + reason);
updateStatus(false);
});
socket.on('connect_error', function(err) {
log('CONNECT ERROR: ' + err.message + ' (' + (err.type || 'unknown') + ')');
});
socket.onAny(function(event, ...args) {
log('RECV ' + event + ': ' + JSON.stringify(args).substring(0,100));
});
socket.connect();
}
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
}
}
function emitTest() {
if (!socket || !socket.connected) {
log('Niepolaczony!');
return;
}
const now = Date.now();
socket.emit('pingtest', now);
log('SENT pingtest ' + now);
}
function forcePolling() {
isPollingOnly = !isPollingOnly;
log('Polling only: ' + isPollingOnly);
connect();
}
// STATUS check co 30s
setInterval(function() {
if (socket && socket.connected) {
const transport = socket.io.engine ? socket.io.engine.transport.name : 'unknown';
log('STATUS OK: ' + transport + ' | SID: ' + (socket.id ? socket.id.substring(0,8) : 'none'));
emitTest();
} else {
log('STATUS: Offline');
}
}, 30000);
// Start
connect();
</script>
</body>
</html>
''')
# =========================================================================================
# SOCKET.IO
# =========================================================================================

View File

@@ -65,7 +65,7 @@ sub vcl_recv {
# }
# ---- STATYCZNE agresywny cache + ignorujemy sesję ----
if (req.url ~ "^/static/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
if (req.url ~ "^/static/" || req.url ~ "^/uploads/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset req.http.Cookie;
unset req.http.Authorization;
return (hash);
@@ -141,8 +141,7 @@ sub vcl_backend_response {
}
# Nie cache'uj statyków, jeśli status ≠ 200
if (bereq.url ~ "^/static/" ||
bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (beresp.status != 200) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
@@ -170,7 +169,7 @@ sub vcl_backend_response {
}
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
if (bereq.url ~ "^/static/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset beresp.http.Set-Cookie;
# Jeśli backend dodał Vary: Cookie, usuńmy ten element (nie wpływa na statyki)
@@ -261,4 +260,4 @@ sub vcl_synth {
# ===== PURGE HANDLER =====
sub vcl_purge {
return (synth(200, "Purged"));
}
}

View File

@@ -4,6 +4,11 @@ services:
container_name: lista-zakupow-app
expose:
- "${APP_PORT:-8000}"
# temporary
#ports:
# - "9281:${APP_PORT:-8000}"
healthcheck:
test:
[

View File

@@ -3,7 +3,8 @@ Flask-SQLAlchemy
Flask-Login
Flask-SocketIO
Flask-Compress
eventlet
#eventlet
gevent-websocket
Werkzeug
Pillow
psutil

View File

@@ -857,32 +857,36 @@ td select.tom-dark {
.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */
/* =========================================================
COMPACT: przyciski akcji na listach (jak navbar)
(podmień za stary blok .btn-group-compact)
COMPACT: przyciski akcji na listach
- Desktop: standard Bootstrap
- <=576px: kompakt
========================================================= */
/* Bazowo (wszystkie ekrany): trochę ciaśniej niż Bootstrap */
.btn-group-compact .btn {
padding: 0.25rem 0.5rem;
/* <=420px: tylko emoji */
@media (max-width: 420px) {
.btn-group-compact .btn-text {
display: none !important;
}
.btn-group-compact .btn {
padding: 0.22rem 0.45rem;
min-width: auto;
font-size: 0.9rem;
line-height: 1.1;
}
}
/* Wąskie ekrany (iPhone 11 / węższe) */
@media (max-width: 420px) {
.btn-group-compact {
flex-wrap: nowrap;
}
/* 421576px: lekko ciaśniej, ale tekst zostaje */
@media (min-width: 421px) and (max-width: 576px) {
.btn-group-compact .btn {
padding: 0.25rem 0.5rem;
font-size: 0.82rem;
line-height: 1.1;
}
.btn-group-compact .btn-text {
display: none !important;
}
.btn-group-compact .btn {
padding: 0.22rem 0.45rem; /* jak navbar */
min-width: auto;
font-size: 0.9rem; /* emoji czytelne */
line-height: 1.1;
}
.btn-group-compact .btn-text {
font-size: 0.75rem;
}
}
/* Medium-narrow screens */
@@ -900,17 +904,16 @@ td select.tom-dark {
/* ================================================
RESPONSIVE NAVBAR - zachowaj teksty, ale mniejsze
RESPONSIVE NAVBAR
================================================ */
/* Wąskie ekrany (np. iPhone 11) */
@media (max-width: 420px) {
/* Navbar: zmniejsz odstępy w poziomie */
.navbar .container-fluid {
gap: 4px;
}
/* Logo: zostaje "Lista Zakupów", ale mniejsze */
.navbar-brand-compact {
font-size: 0.9rem !important;
margin-right: 0.25rem;
@@ -930,7 +933,6 @@ td select.tom-dark {
padding: 0.2rem 0.45rem;
}
/* Przyciski po prawej: tylko emoji (tekst chowamy) */
.nav-buttons-compact .nav-btn-text {
display: none !important;
}
@@ -965,7 +967,6 @@ td select.tom-dark {
font-size: 0.75rem;
}
/* Tu tekst w przyciskach może zostać (ale mniejszy) */
.nav-buttons-compact {
flex-wrap: nowrap;
}
@@ -978,7 +979,6 @@ td select.tom-dark {
}
}
/* Mobile-only / Desktop-only helpery (jeśli nie chcesz polegać na d-sm-*) */
@media (max-width: 420px) {
.user-label-desktop { display: none !important; }
.user-label-mobile { display: inline !important; }
@@ -987,4 +987,50 @@ td select.tom-dark {
@media (min-width: 421px) {
.user-label-desktop { display: inline !important; }
.user-label-mobile { display: none !important; }
}
}
.category-dot-pure {
display: inline-block !important;
width: 14px !important;
height: 14px !important;
border-radius: 50% !important;
border: 2px solid rgba(255, 255, 255, 0.8) !important;
background-clip: content-box, border-box !important;
vertical-align: middle !important;
margin-right: 3px !important;
opacity: 1 !important;
padding: 0 !important;
line-height: 1 !important;
font-size: 0 !important;
text-indent: -9999px !important;
overflow: hidden !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.4) !important;
}
.category-dot-pure::before,
.category-dot-pure::after {
content: none !important;
}
/* Hover efekt */
.category-dot:hover {
transform: scale(1.3) !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important;
}
.list-title {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
max-width: 70% !important;
display: inline-block !important;
}
/* Bardzo małe ekrany */
@media (max-width: 420px) {
.list-title {
max-width: 60% !important;
}
}

View File

@@ -57,11 +57,18 @@
{% else %}
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<!-- Desktop/tablet: tekst -->
<div class="d-none d-sm-flex justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<span class="me-1 user-info-label">Przeglądasz jako</span>
<span class="badge rounded-pill bg-info">niezalogowany/a</span>
</div>
<!-- Mobile: ikonka zamiast tekstu -->
<div class="d-flex d-sm-none justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<span class="me-1" aria-label="Niezalogowany">👥</span>
<span class="badge rounded-pill bg-info">gość</span>
</div>
{% endif %}
{% endif %}
@@ -124,7 +131,7 @@
<footer class="text-center text-secondary small mt-5 mb-3">
<hr class="text-secondary">
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> ·
<a href="https://gitea.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
<a href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
source code
</a>
</p>

View File

@@ -31,10 +31,8 @@
</div>
{% endif %}
{% set month_names = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień",
"październik", "listopad", "grudzień"] %}
{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %}
<!-- Pulpit: zwykły <select> -->
<div class="d-none d-md-flex justify-content-end align-items-center flex-wrap gap-2 mb-3">
<label for="monthSelect" class="text-white small mb-0">📅 Wybierz miesiąc:</label>
<select id="monthSelect" class="form-select form-select-sm bg-dark text-white border-secondary" style="min-width: 180px;">
@@ -50,7 +48,6 @@
</select>
</div>
<!-- Telefon: przycisk otwierający modal -->
<div class="d-md-none mb-3">
<button class="btn btn-outline-light w-100" data-bs-toggle="modal" data-bs-target="#monthPickerModal">
📅 Wybierz miesiąc
@@ -75,15 +72,31 @@
<li class="list-group-item bg-dark text-white">
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
<span class="fw-bold">
{{ l.title }} (Autor: Ty)
{% for cat in l.category_badges %}
<span class="badge rounded-pill text-dark ms-1" style="background-color: {{ cat.color }}; font-size: 0.56rem; opacity: 0.85;">
{{ cat.name }}
<!-- Desktop/tablet: zwykły tekst -->
<span class="d-none d-sm-inline">
{{ l.title }} (Autor: Ty)
</span>
<!-- Mobile: klikalny tytuł -->
<a class="d-inline d-sm-none text-white text-decoration-none"
href="{{ url_for('view_list', list_id=l.id) }}">
{{ l.title }}
</a>
{% for cat in l.category_badges %}
<!-- DESKTOP: nazwa -->
<span class="badge rounded-pill text-dark ms-1 d-none d-sm-inline fw-semibold"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;">
{{ cat.name }}
</span>
<!-- MOBILE -->
<span class="ms-1 d-sm-none category-dot-pure"
style="background-color: {{ cat.color }};"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}"></span>
{% endfor %}
</span>
<!-- Desktop/tablet: bez tooltipów -->
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-none d-sm-flex" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
📂 <span class="btn-text ms-1">Otwórz</span>
@@ -102,30 +115,24 @@
</a>
</div>
<!-- Mobile: tooltipy (bo tekst znika CSS-em) -->
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-flex d-sm-none" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Otwórz">
📂 <span class="btn-text ms-1">Otwórz</span>
</a>
<a href="{{ url_for('shared_list', token=l.share_token) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
<a href="{{ url_for('copy_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Kopiuj">
📋 <span class="btn-text ms-1">Kopiuj</span>
</a>
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="{% if l.is_public %}Ukryj{% else %}Odkryj{% endif %}">
{% if l.is_public %}🙈 <span class="btn-text ms-1">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1">Odkryj</span>{% endif %}
</a>
<a href="{{ url_for('edit_my_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Ustawienia">
⚙️ <span class="btn-text ms-1">Ustawienia</span>
</a>
@@ -134,21 +141,21 @@
<div class="progress progress-dark progress-thin mt-2 position-relative">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ (purchased_count / total_count * 100) if total_count > 0 else 0 }}%" aria-valuemin="0" aria-valuemax="100"></div>
style="width: {{ (purchased_count / total_count * 100) if total_count > 0 else 0 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
{% set not_purchased_count = l.not_purchased_count if l.total_count else 0 %}
<div class="progress-bar bg-warning" role="progressbar"
style="width: {{ (not_purchased_count / total_count * 100) if total_count > 0 else 0 }}%" aria-valuemin="0" aria-valuemax="100"></div>
style="width: {{ (not_purchased_count / total_count * 100) if total_count > 0 else 0 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar bg-transparent" role="progressbar"
style="width: {{ 100 - ((purchased_count + not_purchased_count) / total_count * 100) if total_count > 0 else 100 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
style="width: {{ 100 - ((purchased_count + not_purchased_count) / total_count * 100) if total_count > 0 else 100 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<span class="progress-label small fw-bold {% if percent < 51 %}text-white{% else %}text-dark{% endif %}">
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
{% if l.total_expense > 0 %}
— 💸 {{ '%.2f'|format(l.total_expense) }} PLN
{% endif %}
{% if l.total_expense > 0 %} — 💸 {{ '%.2f'|format(l.total_expense) }} PLN{% endif %}
</span>
</div>
</li>
@@ -178,39 +185,60 @@
<li class="list-group-item bg-dark text-white">
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
<span class="fw-bold">
{{ l.title }} (Autor: {{ l.owner.username if l.owner else '—' }})
{% for cat in l.category_badges %}
<span class="badge rounded-pill text-dark ms-1" style="background-color: {{ cat.color }}; font-size: 0.56rem; opacity: 0.85;">
{{ cat.name }}
<!-- Desktop/tablet: zwykły tekst -->
<span class="d-none d-sm-inline">
{{ l.title }} (Autor: {{ l.owner.username if l.owner else '—' }})
</span>
<!-- Mobile: klikalny tytuł -> shared_list -->
<a class="d-inline d-sm-none fw-bold list-title text-white text-decoration-none"
href="{{ url_for('view_list', list_id=l.id) }}">
{{ l.title }}
</a>
{% for cat in l.category_badges %}
<!-- DESKTOP: nazwa -->
<span class="badge rounded-pill text-dark ms-1 d-none d-sm-inline fw-semibold"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;">
{{ cat.name }}
</span>
<!-- MOBILE -->
<span class="ms-1 d-sm-none category-dot-pure"
style="background-color: {{ cat.color }};"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}"></span>
{% endfor %}
</span>
<!-- Desktop/tablet: bez tooltipów -->
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-none d-sm-flex align-items-center">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-none d-sm-flex" role="group">
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
</div>
<!-- Mobile: tooltipy -->
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex d-sm-none align-items-center"
data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-flex d-sm-none" role="group">
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
</div>
</div>
<div class="progress progress-dark progress-thin mt-2 position-relative">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ (purchased_count / total_count * 100) if total_count > 0 else 0 }}%" aria-valuemin="0" aria-valuemax="100"></div>
style="width: {{ (purchased_count / total_count * 100) if total_count > 0 else 0 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
{% set not_purchased_count = l.not_purchased_count if l.total_count else 0 %}
<div class="progress-bar bg-warning" role="progressbar"
style="width: {{ (not_purchased_count / total_count * 100) if total_count > 0 else 0 }}%" aria-valuemin="0" aria-valuemax="100"></div>
style="width: {{ (not_purchased_count / total_count * 100) if total_count > 0 else 0 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar bg-transparent" role="progressbar"
style="width: {{ 100 - ((purchased_count + not_purchased_count) / total_count * 100) if total_count > 0 else 100 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
style="width: {{ 100 - ((purchased_count + not_purchased_count) / total_count * 100) if total_count > 0 else 100 }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<span class="progress-label small fw-bold {% if percent < 51 %}text-white{% else %}text-dark{% endif %}">
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
@@ -239,7 +267,9 @@
<span>{{ l.title }}</span>
<form action="{{ url_for('edit_my_list', list_id=l.id) }}" method="post" class="d-contents">
<input type="hidden" name="unarchive" value="1">
<button type="submit" class="btn btn-sm btn-outline-light">♻️ Przywróć</button>
<button type="submit" class="btn btn-sm btn-outline-light">
♻️ Przywróć
</button>
</form>
</li>
{% endfor %}
@@ -268,7 +298,8 @@
<div class="d-grid gap-2">
{% for m in month_options %}
{% set year, month = m.split('-') %}
<a href="{{ url_for('main_page', m=m) }}" class="btn btn-outline-light {% if selected_month == m %}active{% endif %}">
<a href="{{ url_for('main_page', m=m) }}"
class="btn btn-outline-light {% if selected_month == m %}active{% endif %}">
{{ month_names[month|int - 1] }} {{ year }}
</a>
{% endfor %}
@@ -287,4 +318,4 @@
<script src="{{ url_for('static_bp.serve_js', filename='select_month.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}
{% endblock %}
{% endblock %}