Compare commits
31 Commits
226b10b5a1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fd7ca2fe6e | |||
| 99ccd937a4 | |||
|
|
d5a2d1b309 | ||
|
|
34cfde795a | ||
|
|
43b5312e35 | ||
|
|
af40974018 | ||
|
|
a4d17492d2 | ||
|
|
a4403a0d33 | ||
|
|
218191a718 | ||
|
|
721387c994 | ||
|
|
3901cc152e | ||
|
|
177fde9e4b | ||
|
|
dc2ece32a0 | ||
|
|
71233ebb75 | ||
|
|
b92127070b | ||
|
|
c22a59c70c | ||
|
|
9e3842fc7b | ||
|
|
3ba1de00e0 | ||
|
|
d0d37f08b9 | ||
|
|
9537eef58d | ||
| bc6dcc5bb7 | |||
|
|
6da7860b59 | ||
| 7202459284 | |||
| 6cc430d422 | |||
|
|
4128d617a7 | ||
|
|
a51e44847e | ||
|
|
45a6ab7249 | ||
|
|
a363fb9ef8 | ||
|
|
2c246ac40a | ||
|
|
43b7b93ffa | ||
|
|
cabc2c6a4a |
22
README.md
22
README.md
@@ -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`.
|
||||
|
||||
329
app.py
329
app.py
@@ -147,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)
|
||||
@@ -350,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")
|
||||
@@ -358,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")
|
||||
@@ -884,8 +985,6 @@ def get_admin_expense_summary():
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
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)
|
||||
@@ -901,7 +1000,7 @@ def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
|
||||
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]
|
||||
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
|
||||
@@ -909,8 +1008,8 @@ def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
|
||||
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_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))
|
||||
|
||||
@@ -920,6 +1019,7 @@ def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
|
||||
|
||||
return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}"
|
||||
|
||||
|
||||
def get_total_expenses_grouped_by_category(
|
||||
show_all, range_type, start_date, end_date, user_id, category_id=None
|
||||
):
|
||||
@@ -1021,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
|
||||
]
|
||||
@@ -1259,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
|
||||
|
||||
@@ -1273,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():
|
||||
@@ -1364,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):
|
||||
@@ -1379,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))
|
||||
|
||||
|
||||
####################################################
|
||||
|
||||
|
||||
@@ -1470,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"
|
||||
@@ -1756,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:
|
||||
@@ -1991,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):
|
||||
@@ -2017,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}%"))
|
||||
|
||||
@@ -2142,9 +2241,14 @@ 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))
|
||||
|
||||
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
|
||||
@@ -2154,12 +2258,14 @@ def view_list(list_id):
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
@@ -2550,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
|
||||
]
|
||||
|
||||
@@ -3788,9 +3894,7 @@ def admin_edit_categories():
|
||||
l.categories.extend(cats)
|
||||
db.session.commit()
|
||||
flash("Zaktualizowano kategorie dla wybranych list", "success")
|
||||
return redirect(
|
||||
url_for("admin_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"})
|
||||
|
||||
@@ -3805,6 +3909,7 @@ def admin_edit_categories():
|
||||
query_string=query_string,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/edit_categories/<int:list_id>/save", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -4000,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")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
FROM python:3.14-rc-trixie
|
||||
FROM python:3.14-trixie
|
||||
#FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
"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)",
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 421–576px: 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
130
static/js/admin_settings.js
Normal 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();
|
||||
})();
|
||||
})();
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</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>
|
||||
|
||||
145
templates/admin/settings.html
Normal file
145
templates/admin/settings.html
Normal 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>5–7</code> (balans dokładności i stabilności).</li>
|
||||
<li><strong>Niskie (1–3):</strong> szybsze, mniejsza wykrywalność trudnych skanów.</li>
|
||||
<li><strong>Średnie (4–7):</strong> dobre na większość paragonów — <em>polecane</em>.</li>
|
||||
<li><strong>Wysokie (8–10):</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 1–2 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 %}
|
||||
@@ -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/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'] %}
|
||||
@@ -128,13 +183,11 @@
|
||||
|
||||
{% 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>
|
||||
|
||||
@@ -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) }}%)
|
||||
|
||||
Reference in New Issue
Block a user