49 Commits

Author SHA1 Message Date
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
Mateusz Gruszczyński
dc2ece32a0 poprawka dla malych ekranow 2026-01-13 08:34:57 +01:00
Mateusz Gruszczyński
71233ebb75 poprawka dla malych ekranow 2026-01-13 08:26:51 +01:00
Mateusz Gruszczyński
b92127070b poprawka dla malych ekranow 2026-01-13 08:18:49 +01:00
Mateusz Gruszczyński
c22a59c70c poprawka dla malych ekranow 2026-01-13 08:13:59 +01:00
Mateusz Gruszczyński
9e3842fc7b poprawka dla malych ekranow 2026-01-13 07:57:43 +01:00
Mateusz Gruszczyński
3ba1de00e0 fix healthcheck 2026-01-12 12:31:18 +01:00
Mateusz Gruszczyński
d0d37f08b9 fix healt in compose 2025-12-24 22:48:38 +01:00
Mateusz Gruszczyński
9537eef58d cahce on /healthcheck 2025-12-24 22:38:15 +01:00
gru
bc6dcc5bb7 Update README.md 2025-11-29 09:56:22 +01:00
Mateusz Gruszczyński
6da7860b59 oci support 2025-11-24 14:17:20 +01:00
gru
7202459284 Update deploy/app/Dockerfile 2025-11-23 22:32:51 +01:00
gru
6cc430d422 Update deploy/app/Dockerfile 2025-11-23 22:26:45 +01:00
Mateusz Gruszczyński
4128d617a7 zakladka ustawien 2025-10-21 12:08:05 +02:00
Mateusz Gruszczyński
a51e44847e zakladka ustawien 2025-10-21 12:03:45 +02:00
Mateusz Gruszczyński
45a6ab7249 zakladka ustawien 2025-10-21 12:02:29 +02:00
Mateusz Gruszczyński
a363fb9ef8 zakladka ustawien 2025-10-21 11:57:53 +02:00
Mateusz Gruszczyński
2c246ac40a zakladka ustawien 2025-10-21 11:44:21 +02:00
Mateusz Gruszczyński
43b7b93ffa zakladka ustawien 2025-10-21 11:32:04 +02:00
Mateusz Gruszczyński
cabc2c6a4a zakladka ustawien 2025-10-21 11:30:34 +02:00
Mateusz Gruszczyński
226b10b5a1 barwy kategorii 2025-10-18 00:22:51 +02:00
Mateusz Gruszczyński
b24748a7b6 barwy kategorii 2025-10-18 00:21:50 +02:00
Mateusz Gruszczyński
11065cd007 barwy kategorii 2025-10-18 00:19:15 +02:00
Mateusz Gruszczyński
05d364bcd4 barwy kategorii 2025-10-18 00:15:06 +02:00
Mateusz Gruszczyński
57a553037b barwy kategorii 2025-10-18 00:01:23 +02:00
Mateusz Gruszczyński
5ed356a61c barwy kategorii 2025-10-17 23:58:56 +02:00
Mateusz Gruszczyński
5da660b4c3 barwy kategorii 2025-10-17 23:57:10 +02:00
Mateusz Gruszczyński
d439002241 barwy kategorii 2025-10-17 23:56:01 +02:00
Mateusz Gruszczyński
4246cde484 poprawki 2025-10-17 23:50:15 +02:00
Mateusz Gruszczyński
a902205960 fix compose 2025-10-08 12:20:48 +02:00
Mateusz Gruszczyński
355b73775f fix w compose 2025-10-07 21:24:52 +02:00
Mateusz Gruszczyński
81744b5c5e kolory kategorii i jedniklikowy wybor kategorii w modalu 2025-10-07 09:10:29 +02:00
Mateusz Gruszczyński
735fc69562 nowa kategoria domyślna 2025-10-07 08:04:53 +02:00
Mateusz Gruszczyński
17a5fd2086 nowa kategoria domyślna 2025-10-07 08:02:20 +02:00
Mateusz Gruszczyński
9986716e9e zmiany uxowe w panelu 2025-10-01 21:27:19 +02:00
Mateusz Gruszczyński
759c78ce87 zmiany uxowe w panelu 2025-10-01 21:21:59 +02:00
Mateusz Gruszczyński
365791cd35 zmiany uxowe w panelu 2025-10-01 21:16:45 +02:00
Mateusz Gruszczyński
08b680f030 minimalizacja js 2025-10-01 20:44:01 +02:00
23 changed files with 1291 additions and 385 deletions

View File

@@ -1,66 +0,0 @@
# =========================
# Stage 1 Build
# =========================
FROM python:3.13-alpine AS builder
WORKDIR /app
# Instalacja bibliotek do kompilacji + zależności runtime
RUN apk add --no-cache \
tesseract-ocr \
tesseract-ocr-data-pol \
poppler-utils \
libjpeg-turbo \
zlib \
libpng \
libwebp \
libffi \
libmagic \
&& apk add --no-cache --virtual .build-deps \
build-base \
jpeg-dev \
zlib-dev \
libpng-dev \
libwebp-dev \
libffi-dev
# Kopiujemy plik wymagań
COPY requirements.txt .
# Instalujemy zależności Pythona do folderu tymczasowego
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# =========================
# Stage 2 Final image
# =========================
FROM python:3.13-alpine
WORKDIR /app
# Instalacja tylko bibliotek runtime (bez dev)
RUN apk add --no-cache \
tesseract-ocr \
tesseract-ocr-data-pol \
poppler-utils \
libjpeg-turbo \
zlib \
libpng \
libwebp \
libffi \
libmagic
# Kopiujemy zbudowane biblioteki z buildera
COPY --from=builder /install /usr/local
# Kopiujemy kod aplikacji
COPY . .
# Ustawiamy entrypoint
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Otwieramy port aplikacji
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -16,12 +16,12 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik
- Python 3.9+
- Docker (opcjonalnie dla produkcji)
## Instalacja lokalna
## Instalacja lokalna (deweloperska)
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
```
@@ -41,14 +41,26 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik
flask --app app.py run
```
## Deploy z Docker Compose
## Deploy z Docker Compose - stack (zalecana)
1. Skonfiguruj `.env`.
2. Uruchom:
2.1 Uruchom: (pgsql)
```bash
docker-compose up --build
bash deploy_docker.sh pgsql
```
2.2 Uruchom: (mysql)
```bash
bash deploy_docker.sh mysql
```
2.3 Uruchom: (sqlite)
```bash
bash deploy_docker.sh sqlite
```
Aplikacja będzie dostępna pod `http://localhost:8000`.

422
app.py
View File

@@ -11,7 +11,6 @@ import re
import traceback
import bcrypt
import colorsys
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
from urllib.parse import urlparse, urlunparse
@@ -148,39 +147,20 @@ WEBP_SAVE_PARAMS = {
"quality": 95, # tylko jeśli lossless=False
}
def read_commit_and_date(filename="version.txt", root_path=None):
def read_commit(filename="version.txt", root_path=None):
base = root_path or os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, filename)
if not os.path.exists(path):
return None, None
return None
try:
commit = open(path, "r", encoding="utf-8").read().strip()
if commit:
commit = commit[:12]
return commit[:12] if commit else None
except Exception:
commit = None
return None
try:
ts = os.path.getmtime(path)
date_str = datetime.fromtimestamp(ts).strftime("%Y.%m.%d")
except Exception:
date_str = None
commit = read_commit("version.txt", root_path=os.path.dirname(__file__)) or "dev"
APP_VERSION = commit
return date_str, commit
deploy_date, commit = read_commit_and_date(
"version.txt", root_path=os.path.dirname(__file__)
)
if not deploy_date:
deploy_date = datetime.now().strftime("%Y.%m.%d")
if not commit:
commit = "dev"
APP_VERSION = f"{deploy_date}+{commit}"
app.config["APP_VERSION"] = APP_VERSION
db = SQLAlchemy(app)
@@ -351,6 +331,118 @@ ShoppingList.permitted_users = db.relationship(
)
class AppSetting(db.Model):
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.Text, nullable=True)
class CategoryColorOverride(db.Model):
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(
db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False
)
color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb"
def get_setting(key: str, default: str | None = None) -> str | None:
s = db.session.get(AppSetting, key)
return s.value if s else default
def set_setting(key: str, value: str | None):
s = db.session.get(AppSetting, key)
if (value or "").strip() == "":
if s:
db.session.delete(s)
else:
if not s:
s = AppSetting(key=key, value=value)
db.session.add(s)
else:
s.value = value
def get_ocr_keywords() -> list[str]:
raw = get_setting("ocr_keywords", None)
if raw:
try:
vals = (
json.loads(raw)
if raw.strip().startswith("[")
else [v.strip() for v in raw.split(",")]
)
return [v for v in vals if v]
except Exception:
pass
# domyślne obecne w kodzie OCR
return [
"razem do zapłaty",
"do zapłaty",
"suma",
"kwota",
"wartość",
"płatność",
"total",
"amount",
]
# 1) nowa funkcja: tylko frazy użytkownika (bez domyślnych)
def get_user_ocr_keywords_only() -> list[str]:
raw = get_setting("ocr_keywords", None)
if not raw:
return []
try:
if raw.strip().startswith("["):
vals = json.loads(raw)
else:
vals = [v.strip() for v in raw.split(",")]
return [v for v in vals if v]
except Exception:
return []
_BASE_KEYWORDS_BLOCK = r"""
(?:
razem\s*do\s*zap[łl][aąo0]ty |
do\s*zap[łl][aąo0]ty |
suma |
kwota |
warto[śćs] |
płatno[śćs] |
total |
amount
)
"""
def priority_keywords_pattern() -> re.Pattern:
user_terms = get_user_ocr_keywords_only()
if user_terms:
escaped = [re.escape(t) for t in user_terms]
user_block = " | ".join(escaped)
combined = rf"""
\b(
{_BASE_KEYWORDS_BLOCK}
| {user_block}
)\b
"""
else:
combined = rf"""\b({_BASE_KEYWORDS_BLOCK})\b"""
return re.compile(combined, re.IGNORECASE | re.VERBOSE)
def category_color_for(c: Category) -> str:
ov = CategoryColorOverride.query.filter_by(category_id=c.id).first()
return ov.color_hex if ov else category_to_color(c.name)
def color_for_category_label(label: str) -> str:
cat = Category.query.filter(func.lower(Category.name) == label.lower()).first()
return category_color_for(cat) if cat else category_to_color(label)
def hash_password(password):
pepper = app.config["BCRYPT_PEPPER"]
peppered = (password + pepper).encode("utf-8")
@@ -359,6 +451,14 @@ def hash_password(password):
return hashed.decode("utf-8")
def get_int_setting(key: str, default: int) -> int:
try:
v = get_setting(key, None)
return int(v) if v is not None and str(v).strip() != "" else default
except Exception:
return default
def check_password(stored_hash, password_input):
pepper = app.config["BCRYPT_PEPPER"]
peppered = (password_input + pepper).encode("utf-8")
@@ -885,14 +985,39 @@ def get_admin_expense_summary():
}
def category_to_color(name):
hash_val = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
hue = (hash_val % 360) / 360.0
saturation = 0.60 + ((hash_val >> 8) % 17) / 100.0
lightness = 0.28 + ((hash_val >> 16) % 11) / 100.0
def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
# Stabilny hash -> int
hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
r, g, b = colorsys.hls_to_rgb(hue, lightness, saturation)
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
# Proste mieszanie bitów, by uniknąć lokalnych skupień
def rotl(x, r, bits=128):
r %= bits
return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1)
mix = hv ^ rotl(hv, 37) ^ rotl(hv, 73) ^ rotl(hv, 91)
# Pełne pokrycie koła barw 0..360
hue_deg = mix % 360
# Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko
gap = (rotl(mix, 17) % (2 * min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap]
hue_deg = (hue_deg + gap) % 360
# DARK profil: niższa jasność i nieco mniejsza saturacja
s = 0.70
l = 0.45
# Wąska wariacja, żeby uniknąć „neonów” i zachować spójność
s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02
l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03
s = min(0.76, max(0.62, s + s_var))
l = min(0.50, max(0.40, l + l_var))
# Konwersja HLS->RGB (colorsys: H,L,S w [0..1])
h = hue_deg / 360.0
r, g, b = colorsys.hls_to_rgb(h, l, s)
return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}"
def get_total_expenses_grouped_by_category(
@@ -996,7 +1121,7 @@ def get_total_expenses_grouped_by_category(
{
"label": cat,
"data": [round(data_map[label].get(cat, 0.0), 2) for label in labels],
"backgroundColor": category_to_color(cat),
"backgroundColor": color_for_category_label(cat),
}
for cat in cats
]
@@ -1234,8 +1359,15 @@ def get_page_args(
def preprocess_image_for_tesseract(image):
# czułość 1..10 (domyślnie 5)
sens = get_int_setting("ocr_sensitivity", 5)
# próg progowy im wyższa czułość, tym niższy próg (więcej czerni)
base_thresh = 150
delta = int((sens - 5) * 8) # krok 8 na stopień
thresh = max(90, min(210, base_thresh - delta))
image = ImageOps.autocontrast(image)
image = image.point(lambda x: 0 if x < 150 else 255)
image = image.point(lambda x: 0 if x < thresh else 255)
image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC)
return image
@@ -1248,21 +1380,7 @@ def extract_total_tesseract(image):
blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE)
priority_keywords = re.compile(
r"""
\b(
razem\s*do\s*zap[łl][aąo0]ty |
do\s*zap[łl][aąo0]ty |
suma |
kwota |
warto[śćs] |
płatno[śćs] |
total |
amount
)\b
""",
re.IGNORECASE | re.VERBOSE,
)
priority_keywords = priority_keywords_pattern()
for line in lines:
if not line.strip():
@@ -1339,7 +1457,14 @@ def is_ip_blocked(ip):
attempts = failed_login_attempts[ip]
while attempts and now - attempts[0] > TIME_WINDOW:
attempts.popleft()
return len(attempts) >= MAX_ATTEMPTS
max_attempts = get_int_setting("max_login_attempts", 10)
return len(attempts) >= max_attempts
def attempts_remaining(ip):
attempts = failed_login_attempts[ip]
max_attempts = get_int_setting("max_login_attempts", 10)
return max(0, max_attempts - len(attempts))
def register_failed_attempt(ip):
@@ -1354,11 +1479,6 @@ def reset_failed_attempts(ip):
failed_login_attempts[ip].clear()
def attempts_remaining(ip):
attempts = failed_login_attempts[ip]
return max(0, MAX_ATTEMPTS - len(attempts))
####################################################
@@ -1445,6 +1565,13 @@ def apply_headers(response):
response.headers["Vary"] = "Accept-Encoding"
return response
# --- healthcheck ---
if request.path == '/healthcheck':
response.headers['Cache-Control'] = 'no-store, no-cache'
response.headers.pop('ETag', None)
response.headers.pop('Vary', None)
return response
# --- redirecty ---
if response.status_code in (301, 302, 303, 307, 308):
response.headers["Cache-Control"] = "no-store"
@@ -1731,8 +1858,7 @@ def main_page():
l.not_purchased_count = not_purchased_count
l.total_expense = latest_expenses_map.get(l.id, 0)
l.category_badges = [
{"name": c.name, "color": category_to_color(c.name)}
for c in l.categories
{"name": c.name, "color": category_color_for(c)} for c in l.categories
]
else:
for l in all_lists:
@@ -1966,7 +2092,6 @@ def edit_my_list(list_id):
)
@app.route("/edit_my_list/<int:list_id>/suggestions", methods=["GET"])
@login_required
def edit_my_list_suggestions(list_id: int):
@@ -1992,10 +2117,9 @@ def edit_my_list_suggestions(list_id: int):
.subquery()
)
query = (
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
.join(subq, subq.c.uid == User.id)
)
query = db.session.query(
User.username, subq.c.grant_count, subq.c.last_grant_id
).join(subq, subq.c.uid == User.id)
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
@@ -2110,10 +2234,6 @@ def create_list():
@app.route("/list/<int:list_id>")
@login_required
# ─────────────────────────────────────────────────────────────────────────────
# Widok listy właściciela dopięcie permitted_users do kontekstu
# ─────────────────────────────────────────────────────────────────────────────
@login_required
def view_list(list_id):
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
@@ -2121,35 +2241,57 @@ def view_list(list_id):
is_owner = current_user.id == shopping_list.owner_id
if not is_owner:
flash("Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", "warning")
flash(
"Nie jesteś właścicielem listy, przekierowano do widoku publicznego.",
"warning",
)
if current_user.is_admin:
flash("W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info")
flash(
"W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info"
)
return redirect(url_for("shared_list", token=shopping_list.share_token))
# Twoja obecna logika ładująca szczegóły listy:
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
total_count = len(items)
purchased_count = len([i for i in items if i.purchased])
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
# Uzupełnienie "added_by_display" — jak u Ciebie:
for item in items:
if item.added_by != shopping_list.owner_id:
item.added_by_display = (item.added_by_user.username if item.added_by_user else "?")
item.added_by_display = (
item.added_by_user.username if item.added_by_user else "?"
)
else:
item.added_by_display = None
# Badges kategorii (jak u Ciebie)
shopping_list.category_badges = [
{"name": c.name, "color": category_to_color(c.name)}
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
# Dane do modala kategorii
# Wszystkie kategorie (do selecta)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in shopping_list.categories}
# ⬅️ NOWE: użytkownicy z uprawnieniami do tej listy (dla modala w list.html)
# Najczęściej używane kategorie właściciela (top N)
popular_categories = (
db.session.query(Category)
.join(
shopping_list_category,
shopping_list_category.c.category_id == Category.id,
)
.join(
ShoppingList,
ShoppingList.id == shopping_list_category.c.shopping_list_id,
)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(Category.id)
.order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc())
.limit(6)
.all()
)
# Użytkownicy z uprawnieniami do listy
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
@@ -2172,7 +2314,8 @@ def view_list(list_id):
is_owner=is_owner,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users, # ⬅️ ważne dla tokenów w modalu
permitted_users=permitted_users,
popular_categories=popular_categories,
)
@@ -2513,7 +2656,7 @@ def shared_list(token=None, list_id=None):
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
shopping_list.category_badges = [
{"name": c.name, "color": category_to_color(c.name)}
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
@@ -3720,10 +3863,10 @@ def recalculate_filesizes_all():
return redirect(url_for("admin_receipts", id="all"))
@app.route("/admin/mass_edit_categories", methods=["GET", "POST"])
@app.route("/admin/edit_categories", methods=["GET", "POST"])
@login_required
@admin_required
def admin_mass_edit_categories():
def admin_edit_categories():
page, per_page = get_page_args(default_per_page=50, max_per_page=200)
lists_query = ShoppingList.query.options(
@@ -3751,14 +3894,12 @@ def admin_mass_edit_categories():
l.categories.extend(cats)
db.session.commit()
flash("Zaktualizowano kategorie dla wybranych list", "success")
return redirect(
url_for("admin_mass_edit_categories", page=page, per_page=per_page)
)
return redirect(url_for("admin_edit_categories", page=page, per_page=per_page))
query_string = urlencode({k: v for k, v in request.args.items() if k != "page"})
return render_template(
"admin/mass_edit_categories.html",
"admin/edit_categories.html",
lists=lists,
categories=categories,
page=page,
@@ -3769,6 +3910,31 @@ def admin_mass_edit_categories():
)
@app.route("/admin/edit_categories/<int:list_id>/save", methods=["POST"])
@login_required
@admin_required
def admin_edit_categories_save(list_id):
l = db.session.get(ShoppingList, list_id)
if not l:
return jsonify(ok=False, error="not_found"), 404
data = request.get_json(silent=True) or {}
ids = data.get("category_ids", [])
try:
ids = [int(x) for x in ids]
except (TypeError, ValueError):
return jsonify(ok=False, error="bad_ids"), 400
l.categories.clear()
if ids:
cats = Category.query.filter(Category.id.in_(ids)).all()
l.categories.extend(cats)
db.session.commit()
return jsonify(ok=True, count=len(l.categories)), 200
@app.route("/admin/list_items/<int:list_id>")
@login_required
@admin_required
@@ -3939,14 +4105,92 @@ def admin_lists_access(list_id=None):
)
@app.route("/healthcheck")
@app.route('/healthcheck')
def healthcheck():
header_token = request.headers.get("X-Internal-Check")
correct_token = app.config.get("HEALTHCHECK_TOKEN")
header_token = request.headers.get('X-Internal-Check')
correct_token = app.config.get('HEALTHCHECK_TOKEN')
if header_token != correct_token:
abort(404)
return "OK", 200
try:
db.session.execute(text('SELECT 1'))
db.session.commit()
response_data = {"status": "ok"}
except Exception as e:
response_data = {
"status": "waiting",
"message": "waiting for db",
"error": str(e)
}
return response_data, 200
@app.route("/admin/settings", methods=["GET", "POST"])
@login_required
@admin_required
def admin_settings():
categories = Category.query.order_by(Category.name.asc()).all()
if request.method == "POST":
ocr_raw = (request.form.get("ocr_keywords") or "").strip()
set_setting("ocr_keywords", ocr_raw)
ocr_sens = (request.form.get("ocr_sensitivity") or "").strip()
set_setting("ocr_sensitivity", ocr_sens)
max_attempts = (request.form.get("max_login_attempts") or "").strip()
set_setting("max_login_attempts", max_attempts)
login_window = (request.form.get("login_window_seconds") or "").strip()
if login_window:
set_setting("login_window_seconds", login_window)
for c in categories:
field = f"color_{c.id}"
vals = request.form.getlist(field)
val = (vals[-1] if vals else "").strip()
existing = CategoryColorOverride.query.filter_by(category_id=c.id).first()
if val and re.fullmatch(r"^#[0-9A-Fa-f]{6}$", val):
if not existing:
db.session.add(CategoryColorOverride(category_id=c.id, color_hex=val))
else:
existing.color_hex = val
else:
if existing:
db.session.delete(existing)
db.session.commit()
flash("Zapisano ustawienia.", "success")
return redirect(url_for("admin_settings"))
override_rows = CategoryColorOverride.query.filter(
CategoryColorOverride.category_id.in_([c.id for c in categories])
).all()
overrides = {row.category_id: row.color_hex for row in override_rows}
auto_colors = {c.id: category_to_color(c.name) for c in categories}
effective_colors = {
c.id: (overrides.get(c.id) or auto_colors[c.id]) for c in categories
}
current_ocr = get_setting("ocr_keywords", "")
ocr_sensitivity = get_int_setting("ocr_sensitivity", 5)
max_login_attempts = get_int_setting("max_login_attempts", 10)
login_window_seconds = get_int_setting("login_window_seconds", 3600)
return render_template(
"admin/settings.html",
categories=categories,
overrides=overrides,
auto_colors=auto_colors,
effective_colors=effective_colors,
current_ocr=current_ocr,
ocr_sensitivity=ocr_sensitivity,
max_login_attempts=max_login_attempts,
login_window_seconds=login_window_seconds,
)
@app.route("/robots.txt")

View File

@@ -77,9 +77,9 @@ class Config:
DEFAULT_CATEGORIES = [
c.strip() for c in os.environ.get(
"DEFAULT_CATEGORIES",
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,"
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Jedzenie poza domem,"
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód"
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód,Dzieci"
).split(",") if c.strip()
]
]

View File

@@ -1,5 +1,4 @@
FROM python:3.14-rc-trixie
FROM python:3.14-trixie
#FROM python:3.13-slim
WORKDIR /app

View File

@@ -75,6 +75,27 @@ sub vcl_recv {
set req.http.X-Forwarded-Proto = "https";
}
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
set req.http.X-Pass-Reason = "internal";
return (pass);
}
if (req.method != "GET" && req.method != "HEAD") {
set req.http.X-Pass-Reason = "method";
return (pass);
}
if (req.http.Authorization) {
set req.http.X-Pass-Reason = "auth";
return (pass);
}
# jeśli chcesz PASS przy cookie:
# if (req.http.Cookie) {
# set req.http.X-Pass-Reason = "cookie";
# return (pass);
# }
return (hash);
}
@@ -107,6 +128,7 @@ sub vcl_backend_response {
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "no-store";
return (deliver);
}
@@ -114,6 +136,7 @@ sub vcl_backend_response {
if (beresp.status >= 300 && beresp.status < 400) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "redirect";
return (deliver);
}
@@ -210,7 +233,14 @@ sub vcl_backend_error {
# ===== DELIVER =====
sub vcl_deliver {
if (obj.uncacheable) {
set resp.http.X-Cache = "PASS";
if (req.http.X-Pass-Reason) {
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
} else if (resp.http.X-Pass-Reason) { # z backendu
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
} else {
set resp.http.X-Cache = "PASS";
}
unset resp.http.X-Pass-Reason;
unset resp.http.Age;
} else if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";

View File

@@ -2,12 +2,16 @@ services:
app:
build: .
container_name: lista-zakupow-app
#ports:
# - "${APP_PORT:-8000}:8000"
expose:
- "${APP_PORT:-8000}"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).getcode() == 200 else sys.exit(1)",
]
interval: 30s
timeout: 10s
retries: 3
@@ -34,13 +38,6 @@ services:
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
environment:
- VARNISH_SIZE=256m
healthcheck:
test: [ "CMD-SHELL", "curl -fsS -H 'X-Internal-Check=${HEALTHCHECK_TOKEN}' http://localhost/healthcheck | grep -q OK" ]
interval: 30s
timeout: 5s
retries: 3
env_file:
- .env
networks:
- lista-zakupow_network
restart: unless-stopped

View File

@@ -1,10 +1,16 @@
#!/bin/sh
# Czekaj na bazę w Pythonie
python _tools/wait_for_db.py
# Jeśli nie przekazano zmiennej środowiskowej DB_ENGINE, ustaw na sqlite
DB_ENGINE=${DB_ENGINE:-sqlite}
# Jak baza gotowa, to migruj li daj informacje
echo "Starting app with database engine: $DB_ENGINE"
# Czekaj na bazę, jeśli jest inna niż sqlite (np. PostgreSQL)
if [ "$DB_ENGINE" != "sqlite" ]; then
python _tools/wait_for_db.py --engine "$DB_ENGINE"
fi
# Migracje i start aplikacji
flask db upgrade 2>/dev/null || flask db_info
# Start aplikacji
exec python app.py

View File

@@ -812,4 +812,225 @@ td select.tom-dark {
.text-danger {
color: var(--danger) !important;
}
}
/* ========== Kolorowe wskaźniki pod pickerem ========== */
.color-indicators .indicator {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: .5rem;
}
.color-indicators .bar {
height: 10px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,.25);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.25);
}
/* ========== Swatch + zapisy heksowe ========== */
.swatch {
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-block;
border: 1px solid rgba(0,0,0,.15);
}
.hex,
.hex-label {
font-variant-numeric: lining-nums;
letter-spacing: .2px;
}
/* ========== OCR textarea ========== */
.settings-ocr-textarea {
font: inherit;
line-height: 1.45;
}
/* ========== Odznaka poziomu czułości ========== */
.sens-badge { font-weight: 600; }
.sens-low { background: rgba(108,117,125,.25); color: #ced4da; } /* szary */
.sens-mid { background: rgba(13,110,253,.25); color: #9ec5fe; } /* niebieski */
.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */
/* =========================================================
COMPACT: przyciski akcji na listach
- Desktop: standard Bootstrap
- <=576px: kompakt
========================================================= */
/* <=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;
}
}
/* 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 {
font-size: 0.75rem;
}
}
/* Medium-narrow screens */
@media (min-width: 421px) and (max-width: 576px) {
.btn-group-compact .btn {
padding: 0.24rem 0.45rem; /* ciaśniej */
font-size: 0.82rem;
line-height: 1.1;
}
.btn-group-compact .btn-text {
font-size: 0.75rem;
}
}
/* ================================================
RESPONSIVE NAVBAR
================================================ */
/* Wąskie ekrany (np. iPhone 11) */
@media (max-width: 420px) {
.navbar .container-fluid {
gap: 4px;
}
.navbar-brand-compact {
font-size: 0.9rem !important;
margin-right: 0.25rem;
white-space: nowrap;
}
.navbar-brand-compact .navbar-brand-text {
font-size: 0.95em;
}
.user-info-compact {
font-size: 0.72rem !important;
line-height: 0.9;
white-space: nowrap;
}
.user-info-compact .badge {
font-size: 0.68rem;
padding: 0.2rem 0.45rem;
}
.nav-buttons-compact .nav-btn-text {
display: none !important;
}
.nav-buttons-compact {
gap: 0.35rem !important;
flex-wrap: nowrap;
}
.nav-buttons-compact .btn {
padding: 0.22rem 0.45rem;
min-width: auto;
line-height: 1.1;
}
}
/* Małe ekrany (np. 421-576px) */
@media (min-width: 421px) and (max-width: 576px) {
.navbar .container-fluid {
gap: 8px;
}
.navbar-brand-compact {
font-size: 1.25rem !important;
white-space: nowrap;
}
.user-info-compact {
font-size: 0.8rem !important;
white-space: nowrap;
}
.user-info-compact .badge {
font-size: 0.75rem;
}
.nav-buttons-compact {
flex-wrap: nowrap;
}
.nav-buttons-compact .btn {
font-size: 0.8rem;
padding: 0.25rem 0.45rem;
}
.nav-buttons-compact .nav-btn-text {
font-size: 0.75rem;
}
}
@media (max-width: 420px) {
.user-label-desktop { display: none !important; }
.user-label-mobile { display: inline !important; }
}
@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;
}
}

130
static/js/admin_settings.js Normal file
View File

@@ -0,0 +1,130 @@
(function () {
const form = document.getElementById("settings-form");
const resetAllBtn = document.getElementById("reset-all");
function ensureHiddenClear(input) {
let hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`);
if (!hidden) {
hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = input.name;
hidden.value = "";
input.parentElement.appendChild(hidden);
}
}
function removeHiddenClear(input) {
const hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`);
if (hidden) hidden.remove();
}
function updatePreview(input) {
const card = input.closest(".col-12, .col-md-6, .col-lg-4");
const hexAutoEl = card.querySelector(".hex-auto");
const hexEffEl = card.querySelector(".hex-effective");
const barAuto = card.querySelector('.bar[data-kind="auto"]');
const barEff = card.querySelector('.bar[data-kind="effective"]');
const raw = (input.value || "").trim();
const autoHex = hexAutoEl.textContent.trim();
const effHex = (raw || autoHex).toUpperCase();
if (barEff) barEff.style.backgroundColor = effHex;
if (hexEffEl) hexEffEl.textContent = effHex;
if (!raw) {
ensureHiddenClear(input);
input.disabled = true;
} else {
removeHiddenClear(input);
input.disabled = false;
}
}
form.querySelectorAll(".use-default").forEach(btn => {
btn.addEventListener("click", () => {
const name = btn.getAttribute("data-target");
const input = form.querySelector(`input[name="${name}"]`);
if (!input) return;
input.value = "";
updatePreview(input);
});
});
form.querySelectorAll(".reset-one").forEach(btn => {
btn.addEventListener("click", () => {
const name = btn.getAttribute("data-target");
const input = form.querySelector(`input[name="${name}"]`);
if (!input) return;
input.value = "";
updatePreview(input);
});
});
resetAllBtn?.addEventListener("click", () => {
form.querySelectorAll('input[type="color"].category-color').forEach(input => {
input.value = "";
updatePreview(input);
});
});
form.querySelectorAll('input[type="color"].category-color').forEach(input => {
updatePreview(input);
input.addEventListener("input", () => updatePreview(input));
input.addEventListener("change", () => updatePreview(input));
});
form.addEventListener("submit", () => {
form.querySelectorAll('input[type="color"].category-color').forEach(updatePreview);
});
form.querySelectorAll(".use-default").forEach(btn => {
btn.addEventListener("click", () => {
const name = btn.getAttribute("data-target");
const input = form.querySelector(`input[name="${name}"]`);
if (!input) return;
const card = input.closest(".col-12, .col-md-6, .col-lg-4") || input.closest(".col-12");
let autoHex = (input.dataset.auto || "").trim();
if (!autoHex && card) {
autoHex = (card.querySelector(".hex-auto")?.textContent || "").trim();
}
if (autoHex && !autoHex.startsWith("#")) autoHex = `#${autoHex}`;
if (autoHex) {
input.disabled = false;
removeHiddenClear(input);
input.value = autoHex;
updatePreview(input);
}
});
});
(function () {
const slider = document.getElementById("ocr_sensitivity");
const badge = document.getElementById("ocr_sens_badge");
const value = document.getElementById("ocr_sens_value");
if (!slider || !badge || !value) return;
function labelFor(v) {
v = Number(v);
if (v <= 3) return "Niski";
if (v <= 7) return "Średni";
return "Wysoki";
}
function clsFor(v) {
v = Number(v);
if (v <= 3) return "sens-low";
if (v <= 7) return "sens-mid";
return "sens-high";
}
function update() {
value.textContent = `(${slider.value})`;
badge.textContent = labelFor(slider.value);
badge.classList.remove("sens-low","sens-mid","sens-high");
badge.classList.add(clsFor(slider.value));
}
slider.addEventListener("input", update);
slider.addEventListener("change", update);
update();
})();
})();

View File

@@ -0,0 +1,43 @@
(function () {
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const saveCategories = async (listId, ids, names, listTitle) => {
try {
const res = await fetch(`/admin/edit_categories/${listId}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_ids: ids })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.error || 'save_failed');
const cats = names.length ? names.join(', ') : 'brak';
showToast(`Zapisano kategorie [${cats}] dla listy <b>${listTitle}</b>`, 'success');
} catch (err) {
console.error('Autosave error:', err);
showToast(`Błąd zapisu kategorii dla listy <b>${listTitle}</b>`, 'danger');
}
};
const timers = new Map();
const debounce = (key, fn, delay = 300) => {
clearTimeout(timers.get(key));
timers.set(key, setTimeout(fn, delay));
};
$$('.form-select[name^="categories_"]').forEach(select => {
const listId = select.getAttribute('data-list-id') || select.name.replace('categories_', '');
const listTitle = select.closest('tr')?.querySelector('td a')?.textContent.trim() || `#${listId}`;
select.addEventListener('change', () => {
const selectedOptions = Array.from(select.options).filter(o => o.selected);
const ids = selectedOptions.map(o => o.value); // <-- ID
const names = selectedOptions.map(o => o.textContent.trim());
debounce(listId, () => saveCategories(listId, ids, names, listTitle));
});
});
const fallback = $('#fallback-save-btn');
if (fallback) fallback.classList.add('d-none');
})();

View File

@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#categoriesModal .category-suggestion').forEach(btn => {
btn.addEventListener('click', () => {
const select = document.getElementById('category_id');
if (!select) return;
select.value = btn.dataset.catId || '';
const form = btn.closest('form');
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
});
});
});

View File

@@ -1,27 +1,45 @@
(function () {
const configs = (window.CROP_CONFIGS && Array.isArray(window.CROP_CONFIGS))
? window.CROP_CONFIGS
: (window.CROP_CONFIG ? [window.CROP_CONFIG] : []);
if (!configs.length) return;
document.addEventListener("DOMContentLoaded", function () {
const cropModal = document.getElementById("adminCropModal");
const cropImage = document.getElementById("adminCropImage");
const spinner = document.getElementById("adminCropLoading");
const saveButton = document.getElementById("adminSaveCrop");
configs.forEach((cfg) => initCropperSet(cfg));
});
function initCropperSet(cfg) {
const {
modalId,
imageId,
spinnerId,
saveBtnId,
endpoint
} = cfg || {};
const cropModal = document.getElementById(modalId);
const cropImage = document.getElementById(imageId);
const spinner = document.getElementById(spinnerId);
const saveButton = document.getElementById(saveBtnId);
if (!cropModal || !cropImage || !spinner || !saveButton) return;
let cropper;
let currentReceiptId;
const currentEndpoint = "/admin/crop_receipt";
const currentEndpoint = endpoint;
cropModal.addEventListener("shown.bs.modal", function (event) {
const button = event.relatedTarget;
const baseSrc = button.getAttribute("data-img-src") || "";
const ver = button.getAttribute("data-version") || Date.now();
const baseSrc = button?.getAttribute("data-img-src") || "";
const ver = button?.getAttribute("data-version") || Date.now();
const sep = baseSrc.includes("?") ? "&" : "?";
cropImage.src = baseSrc + sep + "cb=" + ver;
currentReceiptId = button.getAttribute("data-receipt-id");
currentReceiptId = button?.getAttribute("data-receipt-id");
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
if (cropper) cropper.destroy();
if (cropper && cropper.destroy) cropper.destroy();
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
});
@@ -35,5 +53,5 @@
spinner.classList.remove("d-none");
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
});
});
}
})();

View File

@@ -1,39 +0,0 @@
(function () {
document.addEventListener("DOMContentLoaded", function () {
const cropModal = document.getElementById("userCropModal");
const cropImage = document.getElementById("userCropImage");
const spinner = document.getElementById("userCropLoading");
const saveButton = document.getElementById("userSaveCrop");
if (!cropModal || !cropImage || !spinner || !saveButton) return;
let cropper;
let currentReceiptId;
const currentEndpoint = "/user_crop_receipt";
cropModal.addEventListener("shown.bs.modal", function (event) {
const button = event.relatedTarget;
const baseSrc = button.getAttribute("data-img-src") || "";
const ver = button.getAttribute("data-version") || Date.now();
const sep = baseSrc.includes("?") ? "&" : "?";
cropImage.src = baseSrc + sep + "cb=" + ver;
currentReceiptId = button.getAttribute("data-receipt-id");
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
if (cropper) cropper.destroy();
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
});
cropModal.addEventListener("hidden.bs.modal", function () {
cropUtils.cleanUpCropper(cropImage, cropper);
cropper = null;
});
saveButton.addEventListener("click", function () {
if (!cropper) return;
spinner.classList.remove("d-none");
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
});
});
})();

View File

@@ -13,8 +13,9 @@
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
<a href="{{ url_for('admin_mass_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light btn-sm">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-outline-light btn-sm">⚙️ Ustawienia</a>
</div>
</div>
</div>

View File

@@ -12,25 +12,24 @@
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="alert alert-warning border-warning text-dark" role="alert">
⚠️ <strong>Uwaga!</strong> Przypisanie więcej niż jednej kategorii do listy może zaburzyć
poprawne zliczanie wydatków, ponieważ wydatki tej listy będą jednocześnie
klasyfikowane do kilku kategorii.
⚠️ <strong>Uwaga!</strong> Przypisanie więcej niż jednej kategorii do listy może zaburzyć poprawne zliczanie
wydatków.
</div>
<form method="post">
<div class="card bg-dark text-white mb-5">
<form method="post" id="mass-edit-form">
<div class="card bg-dark text-white mb-4">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark align-middle sortable">
<thead>
<table class="table table-dark align-middle sortable mb-0">
<thead class="position-sticky top-0 bg-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Nazwa listy</th>
<th scope="col">Właściciel</th>
<th scope="col">Data utworzenia</th>
<th scope="col">Data</th>
<th scope="col">Status</th>
<th scope="col">Podgląd produktów</th>
<th scope="col">Kategorie</th>
<th scope="col">Podgląd</th>
<th scope="col" style="min-width: 260px;">Kategorie</th>
</tr>
</thead>
<tbody>
@@ -44,22 +43,17 @@
<td>
{% if l.owner %}
👤 {{ l.owner.username }} ({{ l.owner.id }})
{% else %}
-
{% endif %}
{% else %}-{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.is_archived %}<span
class="badge rounded-pill bg-secondary">Archiwalna</span>{%
endif %}
class="badge rounded-pill bg-secondary me-1">Archiwalna</span>{% endif %}
{% if l.is_temporary %}<span
class="badge rounded-pill bg-warning text-dark">Tymczasowa</span>{%
class="badge rounded-pill bg-warning text-dark me-1">Tymczasowa</span>{%
endif %}
{% if l.is_public %}<span
class="badge rounded-pill bg-success">Publiczna</span>{% else
%}
<span class="badge rounded-pill bg-dark">Prywatna</span>{% endif %}
{% if l.is_public %}<span class="badge rounded-pill bg-success">Publiczna</span>
{% else %}<span class="badge rounded-pill bg-dark">Prywatna</span>{% endif %}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-light preview-btn"
@@ -67,24 +61,25 @@
🔍 Zobacz
</button>
</td>
<td style="min-width: 220px;">
<select name="categories_{{ l.id }}" multiple
class="form-select tom-dark bg-dark text-white border-secondary rounded">
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat in l.categories %}selected{% endif
%}>
{{ cat.name }}
</option>
{% endfor %}
</select>
<td>
<div class="d-flex align-items-center gap-2">
<select name="categories_{{ l.id }}" multiple
class="form-select tom-dark bg-dark text-white border-secondary rounded"
data-list-id="{{ l.id }}"
aria-label="Wybierz kategorie dla listy {{ l.id }}">
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat in l.categories %}selected{%
endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
</td>
</tr>
{% endfor %}
{% if lists|length == 0 %}
<tr>
<td colspan="12" class="text-center py-4">
Brak list zakupowych do wyświetlenia
</td>
<td colspan="12" class="text-center py-4">Brak list zakupowych do wyświetlenia</td>
</tr>
{% endif %}
</tbody>
@@ -92,9 +87,9 @@
</div>
</div>
</div>
<div>
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany</button>
</div>
{# Fallback ukryty przez JS #}
<button type="submit" class="btn btn-sm btn-outline-light" id="fallback-save-btn">💾 Zapisz zmiany</button>
</form>
</div>
</div>
@@ -120,8 +115,7 @@
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{
p }}</a>
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
@@ -132,7 +126,6 @@
</nav>
</div>
<!-- Modal podglądu produktów -->
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
@@ -150,7 +143,9 @@
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_select_admin.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_autosave.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -55,7 +55,7 @@
<input type="hidden" name="action" value="save_changes">
<div class="table-responsive">
<table class="table table-dark align-middle" id="listsTable">
<table class="table table-dark align-middle sortable" id="listsTable">
<thead class="align-middle">
<tr>
<th scope="col" style="width:36px;"></th>
@@ -77,7 +77,7 @@
<input type="hidden" name="visible_ids" value="{{ l.id }}">
</td>
<td class="text-nowrap">#{{ l.id }}</td>
<td class="text-nowrap">{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white text-decoration-none">{{ l.title

View File

@@ -213,7 +213,16 @@
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='admin_receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script>
window.CROP_CONFIG = {
modalId: "adminCropModal",
imageId: "adminCropImage",
spinnerId: "adminCropLoading",
saveBtnId: "adminSaveCrop",
endpoint: "/admin/crop_receipt"
};
</script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Ustawienia{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">⚙️ Ustawienia</h2>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
<form method="post" id="settings-form">
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔎 OCR — słowa kluczowe i czułość</strong>
</div>
<div class="card-body">
<p class="small text-info mb-2">
Dodaj lokalne frazy (CSV lub JSON), np.: <code>summe, gesamtbetrag, importe total</code>
</p>
<textarea
class="form-control settings-ocr-textarea mb-3"
name="ocr_keywords"
rows="3"
placeholder="suma, razem do zapłaty, total"
>{{ current_ocr }}</textarea>
<label for="ocr_sensitivity" class="form-label d-flex align-items-center gap-2">
Poziom czułości OCR
<span id="ocr_sens_badge" class="badge rounded-pill sens-badge">Średni</span>
<span id="ocr_sens_value" class="small">({{ ocr_sensitivity }})</span>
</label>
<input
type="range"
class="form-range"
min="1"
max="10"
step="1"
name="ocr_sensitivity"
id="ocr_sensitivity"
value="{{ ocr_sensitivity }}"
>
<div class="small mt-1">
<ul class="mb-2 ps-3">
<li><strong>Zalecane:</strong> <code>57</code> (balans dokładności i stabilności).</li>
<li><strong>Niskie (13):</strong> szybsze, mniejsza wykrywalność trudnych skanów.</li>
<li><strong>Średnie (47):</strong> dobre na większość paragonów — <em>polecane</em>.</li>
<li><strong>Wysokie (810):</strong> agresywne binaryzowanie — lepsze dla bladych skanów,
ale większe ryzyko fałszywych trafień i wolniejsze działanie.</li>
</ul>
Tip: jeśli pojawiają się „dziwne” sumy — obniż o 12 poziomy.
</div>
</div>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0 d-flex align-items-center justify-content-between">
<strong>🎨 Kolory kategorii</strong>
<button type="button" class="btn btn-outline-light btn-sm" id="reset-all">🔄 Wyczyść nadpisania</button>
</div>
<div class="card-body">
<div class="row g-3" id="categories-grid">
{% for c in categories %}
{% set hex_override = overrides.get(c.id) %}
{% set hex_auto = auto_colors[c.id] %}
{% set hex_effective = effective_colors[c.id] %}
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label d-block mb-2">{{ c.name }}</label>
<div class="input-group">
<input
type="color"
class="form-control form-control-color category-color"
name="color_{{ c.id }}"
value="{{ hex_override or '' }}"
data-auto="{{ hex_auto }}"
{% if not hex_override %}data-empty="1"{% endif %}
aria-label="Kolor kategorii {{ c.name }}"
>
<div class="btn-group" role="group" aria-label="Akcje koloru">
<button type="button"
class="btn btn-outline-light btn-sm reset-one"
data-target="color_{{ c.id }}">
🔄 Reset
</button>
<button type="button"
class="btn btn-outline-light btn-sm use-default"
data-target="color_{{ c.id }}">
🎯 Przywróć domyślny
</button>
</div>
</div>
<div class="color-indicators mt-2">
<div class="indicator">
<span class="badge text-bg-dark me-2">Efektywny</span>
<span class="bar" data-kind="effective" style="background-color: {{ hex_effective }};"></span>
<span class="hex hex-effective ms-2">{{ hex_effective|upper }}</span>
</div>
<div class="indicator mt-1">
<span class="badge text-bg-light me-2">Domyślny</span>
<span class="bar" data-kind="auto" style="background-color: {{ hex_auto }};"></span>
<span class="hex hex-auto ms-2">{{ hex_auto|upper }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔐 Bezpieczeństwo</strong>
</div>
<div class="card-body">
<label for="max_login_attempts" class="form-label">Limit błędnych logowań (hasło główne)</label>
<input
type="number"
class="form-control"
name="max_login_attempts"
id="max_login_attempts"
min="1"
max="20"
value="{{ max_login_attempts }}"
>
<div class="form-text text-muted">
Po przekroczeniu limitu IP zostaje tymczasowo zablokowane.
</div>
</div>
</div>
<div class="mt-4 d-flex">
<div class="btn-group" role="group" aria-label="Akcje ustawień">
<button type="submit" class="btn btn-outline-light">💾 Zapisz</button>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light">❌ Anuluj</a>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{{ url_for('static_bp.serve_css', filename='admin_settings.css') }}?v={{ APP_VERSION }}">
<script src="{{ url_for('static_bp.serve_js', filename='admin_settings.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -9,32 +8,27 @@
{# --- Bootstrap i główny css zawsze --- #}
<link href="{{ url_for('static_bp.serve_css', filename='style.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}?v={{ APP_VERSION }}"
rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{# --- Style CSS ładowane tylko dla niezablokowanych --- #}
{% set exclude_paths = ['/system-auth'] %}
{% if (exclude_paths | select("in", request.path) | list | length == 0)
and has_authorized_cookie
and not is_blocked %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}?v={{ APP_VERSION }}"
rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.min.css') }}?v={{ APP_VERSION }}"
rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
{# --- Cropper CSS tylko dla wybranych podstron --- #}
{% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings_cropper | select("in", request.path) | list | length > 0 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}?v={{ APP_VERSION }}"
rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
{# --- Tom Select CSS tylko dla wybranych podstron --- #}
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/mass_edit_categories'] %}
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings_tomselect | select("in", request.path) | list | length > 0 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='tom-select.bootstrap5.min.css') }}?v={{ APP_VERSION }}"
rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='tom-select.bootstrap5.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
</head>
@@ -42,36 +36,88 @@
<nav class="navbar navbar-dark bg-dark mb-3">
<div class="container-fluid">
<a class="navbar-brand fw-bold fs-4 text-success" href="/">
🛒 <span class="text-warning">Lista</span> Zakupów
<a class="navbar-brand navbar-brand-compact fw-bold fs-4 text-success" href="/">
🛒 <span class="text-warning navbar-brand-text">Lista</span> <span class="navbar-brand-text">Zakupów</span>
</a>
{% if has_authorized_cookie and not is_blocked %}
{% if current_user.is_authenticated %}
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center">
<span class="me-1">Zalogowany:</span>
<span class="badge rounded-pill bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center">
<span class="me-1">Przeglądasz jako</span>
<span class="badge rounded-pill bg-info">niezalogowany/a</span>
</div>
{% endif %}
{% if current_user.is_authenticated %}
<!-- Desktop/tablet: "Zalogowany:" -->
<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">Zalogowany:</span>
<span class="badge rounded-pill bg-success">{{ current_user.username }}</span>
</div>
<!-- Mobile: 👤 zamiast "Zalogowany:" -->
<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="Zalogowany">👤</span>
<span class="badge rounded-pill bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<!-- 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 %}
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">⚙️</a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm">📊</a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">🚪</a>
<!-- Desktop/tablet: bez tooltipów -->
<div class="d-none d-sm-flex align-items-center gap-2 flex-wrap nav-buttons-compact">
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">
⚙️<span class="nav-btn-text ms-1">Panel</span>
</a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm">
📊<span class="nav-btn-text ms-1">Wydatki</span>
</a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">
🚪<span class="nav-btn-text ms-1">Wyloguj</span>
</a>
</div>
<!-- Mobile: tooltipy (bo tekst przycisków znika CSS-em) -->
<div class="d-flex d-sm-none align-items-center gap-2 flex-wrap nav-buttons-compact">
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}"
class="btn btn-outline-light btn-sm"
data-bs-toggle="tooltip" title="Panel admina">
⚙️<span class="nav-btn-text ms-1">Panel</span>
</a>
{% endif %}
<a href="{{ url_for('expenses') }}"
class="btn btn-outline-light btn-sm"
data-bs-toggle="tooltip" title="Wydatki">
📊<span class="nav-btn-text ms-1">Wydatki</span>
</a>
<a href="{{ url_for('logout') }}"
class="btn btn-outline-light btn-sm"
data-bs-toggle="tooltip" title="Wyloguj">
🚪<span class="nav-btn-text ms-1">Wyloguj</span>
</a>
</div>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-outline-light btn-sm">🔑 Zaloguj</a>
<div class="d-flex align-items-center gap-2 flex-wrap nav-buttons-compact">
<a href="{{ url_for('login') }}" class="btn btn-outline-light btn-sm">🔑 Zaloguj</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</nav>
@@ -84,26 +130,36 @@
<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"> source code</a>
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> ·
<a href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
source code
</a>
</p>
<div class="small">v{{ APP_VERSION }}</div>
</footer>
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
{% if not is_blocked %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Tooltips tylko na mobile (bo tylko tam dodajemy data-bs-toggle="tooltip")
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el);
});
{% with messages = get_flashed_messages(with_categories = true) %}
{% for category, message in messages %}
{% set cat = 'info' if not category else ('danger' if category == 'error' else category) %}
{% if message == 'Please log in to access this page.' %}
showToast("Aby uzyskać dostęp do tej strony, musisz być zalogowany.", "danger");
{% else %}
showToast({{ message| tojson }}, "{{ cat }}");
{% endif %}
{% endfor %}
{% endwith %}
});
{% if message == 'Please log in to access this page.' %}
showToast("Aby uzyskać dostęp do tej strony, musisz być zalogowany.", "danger");
{% else %}
showToast({{ message|tojson }}, "{{ cat }}");
{% endif %}
{% endfor %}
{% endwith %}
});
</script>
{% if request.endpoint != 'system_auth' %}
@@ -114,11 +170,10 @@
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}?v={{ APP_VERSION }}"></script>
<script>
let lightbox = GLightbox({
selector: '.glightbox'
});
let lightbox = GLightbox({ selector: '.glightbox' });
</script>
{% set substrings = ['/admin/receipts', '/edit_my_list'] %}
@@ -126,15 +181,13 @@
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/mass_edit_categories'] %}
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script
src="{{ url_for('static_bp.serve_js_lib', filename='tom-select.complete.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='tom-select.complete.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>
</html>

View File

@@ -244,8 +244,17 @@
{% endblock %}
{% block scripts %}
<script>
window.CROP_CONFIG = {
modalId: "userCropModal",
imageId: "userCropImage",
spinnerId: "userCropLoading",
saveBtnId: "userSaveCrop",
endpoint: "/user_crop_receipt"
};
</script>
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='user_receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='select.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>

View File

@@ -204,12 +204,29 @@
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="grantAccessModalLabel">Ustaw kategorię</h5>
<h5 class="modal-title" id="categoriesModalLabel">Ustaw kategorię</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<form method="post" action="{{ url_for('list_settings', list_id=list.id) }}">
<div class="modal-body">
{% if popular_categories %}
<div class="mb-3">
<div class="small text-secondary mb-1">Najczęściej używane:</div>
<div class="d-flex flex-wrap gap-2">
{% for cat in popular_categories %}
<button type="button" class="btn btn-sm btn-outline-light category-suggestion" data-cat-id="{{ cat.id }}">
{{ cat.name }}
</button>
{% endfor %}
<button type="button" class="btn btn-sm btn-outline-secondary category-suggestion" data-cat-id="">
brak
</button>
</div>
</div>
{% endif %}
<div class="mb-4">
<label for="category_id" class="form-label">🏷️ Kategoria listy</label>
<select id="category_id" name="category_id"
@@ -234,11 +251,11 @@
</div>
</div>
</form>
</div>
</div>
</div>
<!-- MODAL: NADAWANIE DOSTĘPU -->
<div class="modal fade" id="grantAccessModal" tabindex="-1" aria-labelledby="grantAccessModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
@@ -320,6 +337,7 @@
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='sort_mode.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='category_modal.js') }}?v={{ APP_VERSION }}"></script>
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
</script>

View File

@@ -31,14 +31,11 @@
</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;">
<select id="monthSelect" class="form-select form-select-sm bg-dark text-white border-secondary" style="min-width: 180px;">
{% for m in month_options %}
{% set year, month = m.split('-') %}
<option value="{{ m }}" {% if selected_month==m %}selected{% endif %}>
@@ -51,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
@@ -61,72 +57,107 @@
{% if current_user.is_authenticated %}
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
Twoje listy
<button type="button" class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal"
data-bs-target="#archivedModal">
<button type="button" class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal" data-bs-target="#archivedModal">
🗄️ Zarchiwizowane
</button>
</h3>
{% if user_lists %}
<ul class="list-group mb-4">
{% for l in user_lists %}
{% set purchased_count = l.purchased_count %}
{% set total_count = l.total_count %}
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
<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>
<div class="btn-group mt-2 mt-md-0" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center text-nowrap">📂 Otwórz</a>
<a href="{{ url_for('shared_list', token=l.share_token) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center text-nowrap">✏️ Odznaczaj</a>
<a href="{{ url_for('copy_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center text-nowrap">📋 Kopiuj</a>
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center text-nowrap">
{% if l.is_public %}🙈 Ukryj{% else %}🐵 Odkryj{% endif %}
<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>
</a>
<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
✏️ <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">
📋 <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">
{% 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">
⚙️ <span class="btn-text ms-1">Ustawienia</span>
</a>
</div>
<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"
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"
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"
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"
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"
data-bs-toggle="tooltip" title="Ustawienia">
⚙️ <span class="btn-text ms-1">Ustawienia</span>
</a>
<a href="{{ url_for('edit_my_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center text-nowrap">⚙️ Ustawienia</a>
</div>
</div>
<div class="progress progress-dark progress-thin mt-2 position-relative">
{# Kupione #}
<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>
{# Niekupione #}
{% 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>
{# Pozostałe #}
<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 %}">
<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>
{% endfor %}
</ul>
@@ -135,10 +166,13 @@
{% endif %}
{% endif %}
<h3 class="mt-4"> {% if current_user.is_authenticated %}Udostępnione i publiczne listy innych użytkowników {% else %}
Publiczne listy innych użytkowników {% endif %}</h3>
<h3 class="mt-4">
{% if current_user.is_authenticated %}
Udostępnione i publiczne listy innych użytkowników
{% else %}
Publiczne listy innych użytkowników
{% endif %}
</h3>
{% set lists_to_show = accessible_lists %}
{% if lists_to_show %}
@@ -147,35 +181,64 @@
{% set purchased_count = l.purchased_count %}
{% set total_count = l.total_count %}
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
<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>
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center text-nowrap">✏️ Odznaczaj</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>
<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) }}%)