feat: add persistent badges and admin badge page

This commit is contained in:
2026-04-13 10:19:38 +02:00
parent 3c99c3683e
commit c36abe82a8
27 changed files with 576 additions and 100 deletions

View File

@@ -8,6 +8,7 @@ from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager
from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg
from .services.badges import sync_existing_badges
from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now
from .services.monthly import archive_months_missing_up_to_previous
@@ -28,6 +29,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
db.create_all()
ensure_schema_and_admins()
seed_badges()
sync_existing_badges()
register_cli(app)

View File

@@ -10,40 +10,131 @@ from .services.notifications import send_due_notifications, send_monthly_winner_
DEFAULT_BADGES = [
{
"key": "early_bird",
"name": "Frühstarter",
"description": "Erledige 3 Aufgaben vor ihrem Fälligkeitsdatum.",
"icon_name": "bell",
"trigger_type": "early_finisher_count",
"threshold": 3,
"key": "first_wipe",
"name": "Erster Wisch",
"description": "Erledige deine erste Aufgabe.",
"icon_name": "sparkles",
"trigger_type": "first_task_completed",
"threshold": 1,
"bonus_points": 5,
},
{
"key": "warmed_up",
"name": "Warmgelaufen",
"description": "Erledige 10 Aufgaben insgesamt.",
"icon_name": "fire",
"trigger_type": "total_tasks_completed",
"threshold": 10,
"bonus_points": 10,
},
{
"key": "on_time_streak",
"name": "Sauberer Lauf",
"description": "Erledige Aufgaben an 3 Tagen in Folge.",
"icon_name": "check",
"trigger_type": "streak_days",
"threshold": 3,
"key": "punctuality_pro",
"name": "Pünktlichkeitsprofi",
"description": "Erledige 10 Aufgaben pünktlich.",
"icon_name": "check-double",
"trigger_type": "on_time_tasks_completed",
"threshold": 10,
"bonus_points": 15,
},
{
"key": "task_sprinter",
"name": "Putz-Sprinter",
"description": "Schließe 8 Aufgaben in einem Monat ab.",
"icon_name": "trophy",
"trigger_type": "monthly_task_count",
"threshold": 8,
"key": "early_bird",
"name": "Der frühe Vogel",
"description": "Erledige eine Aufgabe mindestens 1 Tag vor der Deadline.",
"icon_name": "calendar-day",
"trigger_type": "early_tasks_completed",
"threshold": 1,
"bonus_points": 8,
},
{
"key": "early_starter",
"name": "Frühstarter",
"description": "Erledige 5 Aufgaben mindestens 1 Tag vor der Deadline.",
"icon_name": "rocket-launch",
"trigger_type": "early_tasks_completed",
"threshold": 5,
"bonus_points": 15,
},
{
"key": "weekly_flow",
"name": "Wochenflow",
"description": "Erledige 7 Tage in Folge mindestens eine Aufgabe.",
"icon_name": "flag-checkered",
"trigger_type": "streak_days",
"threshold": 7,
"bonus_points": 20,
},
{
"key": "monthly_champion",
"name": "Monatssieger",
"description": "Gewinne einen Monat mit den meisten Punkten.",
"icon_name": "trophy",
"trigger_type": "monthly_win_count",
"threshold": 1,
"bonus_points": 25,
},
{
"key": "title_defender",
"name": "Titelverteidiger",
"description": "Gewinne 2 Monate in Folge.",
"icon_name": "crown",
"trigger_type": "consecutive_month_wins",
"threshold": 2,
"bonus_points": 30,
},
{
"key": "boss_battle",
"name": "Bosskampf",
"description": "Erledige eine Aufgabe mit besonders hohem Punktwert.",
"icon_name": "bolt",
"trigger_type": "high_point_task",
"threshold": 25,
"bonus_points": 12,
},
{
"key": "foreign_savior",
"name": "Fremdretter",
"description": "Erledige 5 Aufgaben, die ursprünglich jemand anderem zugewiesen waren.",
"icon_name": "users-crown",
"trigger_type": "foreign_tasks_completed",
"threshold": 5,
"bonus_points": 18,
},
{
"key": "white_glove",
"name": "Weiße Weste",
"description": "Bleibe einen ganzen Monat ohne überfällige Aufgabe.",
"icon_name": "shield",
"trigger_type": "clean_month",
"threshold": 1,
"bonus_points": 20,
},
{
"key": "comeback_kid",
"name": "Comeback Kid",
"description": "Gewinne nach einem verlorenen Monat den nächsten Monat.",
"icon_name": "award",
"trigger_type": "comeback_win",
"threshold": 1,
"bonus_points": 18,
},
]
def seed_badges() -> None:
wanted_keys = {payload["key"] for payload in DEFAULT_BADGES}
BadgeDefinition.query.filter(~BadgeDefinition.key.in_(wanted_keys)).delete(synchronize_session=False)
for payload in DEFAULT_BADGES:
badge = BadgeDefinition.query.filter_by(key=payload["key"]).first()
if not badge:
db.session.add(BadgeDefinition(**payload))
continue
badge.name = payload["name"]
badge.description = payload["description"]
badge.icon_name = payload["icon_name"]
badge.trigger_type = payload["trigger_type"]
badge.threshold = payload["threshold"]
badge.bonus_points = payload["bonus_points"]
badge.active = True
db.session.commit()

View File

@@ -46,6 +46,13 @@ class User(UserMixin, TimestampMixin, db.Model):
lazy=True,
)
subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan")
awarded_badges = db.relationship(
"UserBadge",
backref="user",
lazy=True,
cascade="all, delete-orphan",
order_by="desc(UserBadge.awarded_at)",
)
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
@@ -165,3 +172,15 @@ class BadgeDefinition(TimestampMixin, db.Model):
threshold = db.Column(db.Integer, nullable=False, default=1)
bonus_points = db.Column(db.Integer, nullable=False, default=0)
active = db.Column(db.Boolean, nullable=False, default=True)
user_badges = db.relationship("UserBadge", backref="badge_definition", lazy=True, cascade="all, delete-orphan")
class UserBadge(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
badge_definition_id = db.Column(db.Integer, db.ForeignKey("badge_definition.id"), nullable=False, index=True)
awarded_at = db.Column(db.DateTime, nullable=False, default=utcnow, index=True)
context = db.Column(db.Text, nullable=True)
__table_args__ = (db.UniqueConstraint("user_id", "badge_definition_id", name="uq_user_badge_once"),)

View File

@@ -10,6 +10,7 @@ from werkzeug.utils import secure_filename
from ..extensions import csrf, db
from ..forms import AdminUserForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User
from ..services.badges import earned_badges_for_user
from ..services.notifications import push_enabled
@@ -23,6 +24,13 @@ def _require_admin():
return True
def _settings_tabs():
tabs = [("settings.index", "Profil & Team", "gear")]
if current_user.is_admin:
tabs.append(("settings.badges", "Badges", "award"))
return tabs
def _save_avatar(file_storage) -> str:
filename = secure_filename(file_storage.filename or "")
ext = Path(filename).suffix.lower() or ".png"
@@ -51,17 +59,32 @@ def index():
flash("Deine Einstellungen wurden gespeichert.", "success")
return redirect(url_for("settings.index"))
badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all()
subscriptions = PushSubscription.query.filter_by(user_id=current_user.id).all()
return render_template(
"settings/index.html",
form=form,
admin_form=admin_form,
badges=badges,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
earned_badges=earned_badges_for_user(current_user.id),
push_ready=push_enabled(),
vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"],
has_subscription=bool(subscriptions),
settings_tabs=_settings_tabs(),
active_settings_tab="settings.index",
)
@bp.route("/badges")
@login_required
def badges():
if not _require_admin():
return redirect(url_for("settings.index"))
badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all()
return render_template(
"settings/badges.html",
badges=badges,
settings_tabs=_settings_tabs(),
active_settings_tab="settings.badges",
)
@@ -76,7 +99,7 @@ def update_badge(badge_id: int):
badge.active = request.form.get("active") == "on"
db.session.commit()
flash(f"Badge „{badge.name}“ wurde aktualisiert.", "success")
return redirect(url_for("settings.index"))
return redirect(url_for("settings.badges"))
@bp.route("/users", methods=["POST"])

View File

@@ -1,17 +1,21 @@
from __future__ import annotations
import json
from collections import defaultdict
from datetime import date, timedelta
from datetime import date, datetime, time, timedelta
from ..models import BadgeDefinition, TaskInstance
from sqlalchemy import and_
from ..extensions import db
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge
from .dates import month_bounds, previous_month
def _max_day_streak(days: set[date]) -> int:
if not days:
return 0
streak = 1
best = 1
ordered = sorted(days)
best = streak = 1
for previous, current in zip(ordered, ordered[1:]):
if current == previous + timedelta(days=1):
streak += 1
@@ -21,33 +25,166 @@ def _max_day_streak(days: set[date]) -> int:
return best
def compute_badge_awards(definitions: list[BadgeDefinition], completed_tasks: list[TaskInstance]) -> list[dict]:
by_type: dict[str, int] = defaultdict(int)
completion_days: set[date] = set()
def award_badge(user: User, badge_key: str, *, awarded_at: datetime | None = None, context: dict | None = None) -> bool:
definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
if not definition:
return False
for task in completed_tasks:
if not task.completed_at:
continue
existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
if existing:
return False
db.session.add(
UserBadge(
user_id=user.id,
badge_definition_id=definition.id,
awarded_at=awarded_at or datetime.utcnow(),
context=json.dumps(context, sort_keys=True) if context else None,
)
)
return True
def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]:
start, end = month_bounds(year, month)
return (
UserBadge.query.filter(
UserBadge.user_id == user_id,
UserBadge.awarded_at >= start,
UserBadge.awarded_at < end,
)
.join(BadgeDefinition)
.filter(BadgeDefinition.active.is_(True))
.order_by(UserBadge.awarded_at.asc())
.all()
)
def earned_badges_for_user(user_id: int) -> list[UserBadge]:
return (
UserBadge.query.filter_by(user_id=user_id)
.join(BadgeDefinition)
.order_by(UserBadge.awarded_at.desc())
.all()
)
def _completion_metrics(user: User) -> dict[str, int]:
tasks = (
TaskInstance.query.filter(
TaskInstance.completed_by_user_id == user.id,
TaskInstance.completed_at.isnot(None),
)
.order_by(TaskInstance.completed_at.asc())
.all()
)
completion_days: set[date] = set()
metrics = defaultdict(int)
max_points = 0
for task in tasks:
completion_day = task.completed_at.date()
completion_days.add(completion_day)
by_type["monthly_task_count"] += 1
if task.due_date and completion_day < task.due_date:
by_type["early_finisher_count"] += 1
if task.due_date and completion_day <= task.due_date:
by_type["on_time_count"] += 1
metrics["first_task_completed"] += 1
metrics["total_tasks_completed"] += 1
if completion_day <= task.due_date:
metrics["on_time_tasks_completed"] += 1
if completion_day <= task.due_date - timedelta(days=1):
metrics["early_tasks_completed"] += 1
if task.assigned_user_id and task.assigned_user_id != user.id:
metrics["foreign_tasks_completed"] += 1
max_points = max(max_points, task.points_awarded)
by_type["streak_days"] = _max_day_streak(completion_days)
metrics["streak_days"] = _max_day_streak(completion_days)
metrics["high_point_task"] = max_points
return metrics
def evaluate_task_badges(user: User) -> list[str]:
definitions = BadgeDefinition.query.filter_by(active=True).all()
metrics = _completion_metrics(user)
unlocked: list[str] = []
awards = []
for definition in definitions:
metric_value = by_type.get(definition.trigger_type, 0)
if definition.active and metric_value >= definition.threshold:
awards.append(
{
"definition": definition,
"metric_value": metric_value,
"bonus_points": definition.bonus_points,
}
)
return awards
metric_value = metrics.get(definition.trigger_type)
if metric_value is None:
continue
if metric_value >= definition.threshold and award_badge(user, definition.key):
unlocked.append(definition.name)
if unlocked:
db.session.commit()
return unlocked
def _month_end_award_time(year: int, month: int) -> datetime:
_, end = month_bounds(year, month)
return end - timedelta(seconds=1)
def _user_had_clean_month(user_id: int, year: int, month: int) -> bool:
start_date = date(year, month, 1)
end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date()
tasks = TaskInstance.query.filter(
TaskInstance.assigned_user_id == user_id,
TaskInstance.due_date >= start_date,
TaskInstance.due_date <= end_date,
).all()
if not tasks:
return False
for task in tasks:
if not task.completed_at:
return False
if task.completed_at.date() > task.due_date:
return False
return True
def _winner_user_ids(year: int, month: int) -> set[int]:
rows = MonthlyScoreSnapshot.query.filter_by(year=year, month=month, rank=1).all()
return {row.user_id for row in rows}
def evaluate_monthly_badges(year: int, month: int) -> list[str]:
award_time = _month_end_award_time(year, month)
unlocked: list[str] = []
winners = _winner_user_ids(year, month)
previous_year, previous_month_value = previous_month(year, month)
previous_winners = _winner_user_ids(previous_year, previous_month_value)
for user in User.query.order_by(User.id.asc()).all():
if user.id in winners:
if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Monatssieger")
if user.id in previous_winners:
if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Titelverteidiger")
elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first():
if award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Comeback Kid")
if _user_had_clean_month(user.id, year, month):
if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Weiße Weste")
if unlocked:
db.session.commit()
return unlocked
def sync_existing_badges() -> None:
for user in User.query.order_by(User.id.asc()).all():
evaluate_task_badges(user)
archived_months = (
db.session.query(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
.group_by(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
.order_by(MonthlyScoreSnapshot.year.asc(), MonthlyScoreSnapshot.month.asc())
.all()
)
for row in archived_months:
evaluate_monthly_badges(row.year, row.month)

View File

@@ -3,11 +3,9 @@ from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from sqlalchemy import extract, select
from ..extensions import db
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User
from .badges import compute_badge_awards
from ..models import MonthlyScoreSnapshot, TaskInstance, User
from .badges import badge_awards_for_month, evaluate_monthly_badges
from .dates import local_now, month_bounds, next_month, previous_month
@@ -21,7 +19,6 @@ def _build_ranking(rows: list[dict]) -> list[dict]:
def compute_monthly_scores(year: int, month: int) -> list[dict]:
start, end = month_bounds(year, month)
users = User.query.order_by(User.name.asc()).all()
badges = BadgeDefinition.query.filter_by(active=True).all()
completed_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.isnot(None),
TaskInstance.completed_at >= start,
@@ -37,8 +34,8 @@ def compute_monthly_scores(year: int, month: int) -> list[dict]:
for user in users:
personal_tasks = tasks_by_user.get(user.id, [])
base_points = sum(task.points_awarded for task in personal_tasks)
awards = compute_badge_awards(badges, personal_tasks)
bonus_points = sum(award["bonus_points"] for award in awards)
earned_badges = badge_awards_for_month(user.id, year, month)
bonus_points = sum(badge.badge_definition.bonus_points for badge in earned_badges if badge.badge_definition.active)
rows.append(
{
"user": user,
@@ -46,7 +43,7 @@ def compute_monthly_scores(year: int, month: int) -> list[dict]:
"bonus_points": bonus_points,
"total_points": base_points + bonus_points,
"completed_tasks_count": len(personal_tasks),
"badges": awards,
"badges": earned_badges,
}
)
return _build_ranking(rows)
@@ -100,6 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
)
)
db.session.commit()
evaluate_monthly_badges(year, month)
year, month = next_month(year, month)
@@ -120,4 +118,3 @@ def get_snapshot_rows(year: int, month: int) -> list[MonthlyScoreSnapshot]:
.order_by(MonthlyScoreSnapshot.rank.asc(), MonthlyScoreSnapshot.total_points.desc())
.all()
)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select
from ..extensions import db
from ..models import TaskInstance, TaskTemplate
from .badges import evaluate_task_badges
from .dates import add_months, today_local
@@ -122,5 +123,6 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
task.status = "completed"
ensure_next_recurring_task(task)
db.session.commit()
if task.completed_by_user:
evaluate_task_badges(task.completed_by_user)
return task

View File

@@ -330,7 +330,8 @@ p {
.task-grid,
.scoreboard,
.archive-list,
.badge-settings {
.badge-settings,
.earned-badges-grid {
display: grid;
gap: 16px;
}
@@ -389,6 +390,27 @@ p {
gap: 8px;
}
.earned-badge {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.08);
color: var(--primary-strong);
font-weight: 700;
}
.earned-badge__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -705,6 +727,66 @@ p {
padding: 18px;
}
.badge-card {
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
align-items: start;
}
.badge-card--earned {
background: rgba(37, 99, 235, 0.03);
}
.badge-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(52, 211, 153, 0.12));
color: var(--primary-strong);
}
.badge-card__icon .nav-icon,
.badge-card__icon .nav-icon svg {
width: 26px;
height: 26px;
}
.badge-card__body {
display: grid;
gap: 8px;
}
.settings-tabs {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
padding: 6px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(132, 152, 190, 0.18);
box-shadow: 0 12px 30px rgba(58, 82, 128, 0.1);
}
.settings-tab {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 16px;
color: var(--muted);
font-weight: 700;
}
.settings-tab.is-active {
background: #fff;
color: var(--primary-strong);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.08);
}
.push-box {
display: grid;
gap: 18px;
@@ -767,13 +849,14 @@ p {
.bottom-nav {
position: fixed;
left: 14px;
right: 14px;
left: 50%;
transform: translateX(-50%);
width: min(calc(100vw - 52px), 560px);
bottom: calc(10px + env(safe-area-inset-bottom));
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 10px;
gap: 6px;
padding: 8px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(16px);
@@ -786,13 +869,14 @@ p {
.nav-link {
display: grid;
justify-items: center;
gap: 6px;
padding: 10px 6px;
gap: 5px;
padding: 10px 4px;
color: var(--muted);
border-radius: 16px;
text-align: center;
font-size: 0.73rem;
font-size: 0.69rem;
font-weight: 700;
line-height: 1.1;
}
.bottom-nav__item.is-active,
@@ -855,7 +939,8 @@ p {
}
.form-grid--two,
.badge-settings {
.badge-settings,
.earned-badges-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M106.7 256L134.2 301.1L136.6 305.1L136.5 309.8L135.3 362.6L181.7 387.9L185.8 390.2L188.1 394.3L213.4 440.7L266.2 439.5L270.9 439.4L274.9 441.8L320 469.3L365.1 441.8L369.1 439.4L373.8 439.5L426.6 440.7L451.9 394.3L454.2 390.2L458.3 387.9L504.7 362.6L503.5 309.8L503.4 305.1L505.8 301.1L533.3 256L505.8 210.9L503.4 206.9L503.5 202.2L504.7 149.4L458.3 124.1L454.2 121.8L451.9 117.7L426.6 71.3L373.8 72.5L369.1 72.6L365.1 70.2L320 42.7L274.9 70.2L270.9 72.6L266.2 72.5L213.4 71.3L188.1 117.7L185.8 121.8L181.7 124.1L135.3 149.4L136.5 202.2L136.6 206.9L134.2 210.9L106.7 256zM155 548.8L244.7 597.1L306.8 479.9L266.6 455.4L207.6 456.8L155 548.8zM432 256C432 317.9 381.9 368 320 368C258.1 368 208 317.9 208 256C208 194.1 258.1 144 320 144C381.9 144 432 194.1 432 256zM333.2 479.9L395.3 597.1L485 548.8L432.4 456.8L373.4 455.4L333.2 479.9z"/><path fill="currentColor" d="M320 24L373.4 56.6L436 55.1L466 110L520.9 140L519.4 202.6L552 256L519.4 309.4L520.9 372L466 402L443.3 443.6L503 548L507.1 555.1L499.9 559L395.9 615L388.8 618.8L385 611.7L320.1 489.1L255.2 611.7L251.4 618.8L244.3 615L140.3 559L133.1 555.1L137.2 548L196.9 443.6L174.2 402L119.3 372L120.8 309.4L88.2 256L120.8 202.6L119.3 140L174.2 110L204.2 55.1L266.8 56.6L320.2 24zM333.2 479.9L395.3 597.1L485 548.8L432.4 456.8L373.4 455.4L333.2 479.9zM266.5 455.4L207.5 456.8L154.9 548.8L244.6 597.1L306.7 479.9L266.5 455.4zM373.8 72.6L369.1 72.7L365.1 70.3L320 42.8L274.9 70.3L270.9 72.7L266.2 72.6L213.4 71.4L188.1 117.8L185.8 121.9L181.7 124.2L135.3 149.5L136.5 202.3L136.6 207L134.2 211L106.7 256.1L134.2 301.2L136.6 305.2L136.5 309.9L135.3 362.7L181.7 388L185.8 390.3L188.1 394.4L213.4 440.8L266.2 439.6L270.9 439.5L274.9 441.9L320 469.4L365.1 441.9L369.1 439.5L373.8 439.6L426.6 440.8L451.9 394.4L454.2 390.3L458.3 388L504.7 362.7L503.5 309.9L503.4 305.2L505.8 301.2L533.3 256.1L505.8 211L503.4 207L503.5 202.3L504.7 149.5L458.3 124.2L454.2 121.9L451.9 117.8L426.6 71.4L373.8 72.6zM320 144C381.9 144 432 194.1 432 256C432 317.9 381.9 368 320 368C258.1 368 208 317.9 208 256C208 194.1 258.1 144 320 144zM416 256C416 203 373 160 320 160C267 160 224 203 224 256C224 309 267 352 320 352C373 352 416 309 416 256z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M80 320C80 352.6 98.3 380.9 125.2 395.2L131 398.3L129.1 404.6C120.2 433.7 127.2 466.7 150.2 489.7C173.2 512.7 206.2 519.8 235.3 510.8L241.6 508.9L244.7 514.7C259.1 541.7 287.4 560 320 560C352.6 560 380.9 541.7 395.2 514.8L398.3 509L404.6 510.9C433.7 519.8 466.7 512.8 489.7 489.8C512.7 466.8 519.8 433.8 510.8 404.7L508.9 398.4L514.7 395.3C541.7 380.9 560 352.6 560 320C560 287.4 541.7 259.1 514.8 244.8L509 241.7L510.9 235.4C519.8 206.3 512.8 173.3 489.8 150.3C466.8 127.3 433.8 120.2 404.7 129.2L398.4 131.1L395.3 125.3C380.9 98.3 352.6 80 320 80C287.4 80 259.1 98.3 244.8 125.2L241.7 131L235.4 129.1C206.3 120.2 173.3 127.2 150.3 150.2C127.3 173.2 120.2 206.2 129.2 235.3L131.1 241.6L125.3 244.7C98.3 259.1 80 287.4 80 320zM236.8 302.6L249.4 292.8L254.3 299.1L303.2 362L385.2 227.9L389.4 221.1L403.1 229.4L398.9 236.2L310.9 380.2L304.9 390.1L297.8 381L241.8 309L236.9 302.7z"/><path fill="currentColor" d="M244.8 125.2C259.1 98.3 287.4 80 320 80C352.6 80 380.9 98.3 395.2 125.2L398.3 131L404.6 129.1C433.7 120.2 466.7 127.2 489.7 150.2C512.7 173.2 519.8 206.2 510.8 235.3L508.9 241.6L514.7 244.7C541.7 259.1 560 287.4 560 320C560 352.6 541.7 380.9 514.8 395.2L509 398.3L510.9 404.6C519.8 433.7 512.8 466.7 489.8 489.7C466.8 512.7 433.8 519.8 404.7 510.8L398.4 508.9L395.3 514.7C380.9 541.7 352.6 560 320 560C287.4 560 259.1 541.7 244.8 514.8L241.7 509L235.4 510.9C206.3 519.8 173.3 512.8 150.3 489.8C127.3 466.8 120.2 433.8 129.2 404.7L131.1 398.4L125.3 395.3C98.3 380.9 80 352.6 80 320C80 287.4 98.3 259.1 125.2 244.8L131 241.7L129.1 235.4C120.2 206.3 127.2 173.3 150.2 150.3C173.2 127.3 206.2 120.2 235.3 129.2L241.6 131.1L244.7 125.3zM320 64C283.6 64 251.7 83.3 233.9 112.2C200.9 104.3 164.7 113.3 139 139C113.3 164.7 104.3 200.9 112.2 233.9C83.3 251.7 64 283.6 64 320C64 356.4 83.3 388.3 112.2 406.1C104.3 439.1 113.3 475.3 139 501C164.7 526.7 200.9 535.7 233.9 527.8C251.7 556.7 283.6 576 320 576C356.4 576 388.3 556.7 406.1 527.8C439.1 535.7 475.3 526.7 501 501C526.7 475.3 535.7 439.1 527.8 406.1C556.7 388.3 576 356.4 576 320C576 283.6 556.7 251.7 527.8 233.9C535.7 200.9 526.7 164.7 501 139C475.3 113.3 439 104.3 406.1 112.2C388.3 83.3 356.4 64 320 64zM398.8 236.2L403 229.4L389.3 221.1L385.1 227.9L303.1 362L254.2 299.1L249.3 292.8L236.7 302.6L241.6 308.9L297.6 380.9L304.7 390L310.7 380.1L398.7 236.1z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M119.7 360L120.3 360L120.3 359.1L119.7 360zM154.9 312L264.3 312L264.3 478.7L421.7 264L312.3 264L312.3 97.3L154.9 312zM53 369.8L317 9.8L360.4 24L360.4 168L504.4 168L523.8 206.2L259.8 566.2L216.4 552L216.4 408L72.4 408L53 369.8z"/></svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112 144L112 528L528 528L528 144L432 144L432 176L416 176L416 144L224 144L224 176L208 176L208 144L112 144zM176 304L336 304L336 464L176 464L176 304z"/><path fill="currentColor" d="M224 64L224 128L416 128L416 64L432 64L432 128L544 128L544 544L96 544L96 128L208 128L208 64L224 64zM208 168L208 144L112 144L112 528L528 528L528 144L432 144L432 176L416 176L416 144L224 144L224 176L208 176L208 168zM192 320L192 448L320 448L320 320L192 320zM176 304L336 304L336 464L176 464L176 304z"/></svg>

After

Width:  |  Height:  |  Size: 752 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d=""/><path fill="currentColor" d="M370.6 104.7L375.3 98.3L362.4 88.8L357.7 95.2L219.2 283.7L153.8 218.3L148.1 212.6L136.8 223.9L142.5 229.6L214.5 301.6L221.1 308.2L226.6 300.7L370.6 104.7zM504.6 234.7L509.3 228.3L496.4 218.8L491.7 225.2L281.2 511.7L179.8 410.3L174.1 404.6L162.8 415.9L168.5 421.6L276.5 529.6L283.1 536.2L288.6 528.7L504.6 234.7z"/></svg>

After

Width:  |  Height:  |  Size: 624 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M102.3 253.4L142.7 496L497.1 496L537.5 253.4C530.4 250.7 524.3 246 519.8 240L419.3 287.3L413.1 290.2L409.3 284.5L326 159.6C324 159.9 322 160.1 319.9 160.1C317.8 160.1 315.8 159.9 313.8 159.6L230.5 284.5L226.7 290.2L220.5 287.3L120 240C115.5 246 109.4 250.7 102.3 253.4z"/><path fill="currentColor" d="M296 120C296 106.7 306.7 96 320 96C333.3 96 344 106.7 344 120C344 133.3 333.3 144 320 144C306.7 144 296 133.3 296 120zM326.1 159.5L409.4 284.4L413.2 290.1L419.4 287.2L519.9 239.9C524.4 245.9 530.5 250.6 537.6 253.3L497.2 496L142.8 496L102.3 253.4C109.4 250.7 115.5 246 120 240L220.5 287.3L226.7 290.2L230.5 284.5L313.8 159.6C315.8 159.9 317.8 160.1 319.9 160.1C322 160.1 324 159.9 326 159.6zM341.5 153.7C352.6 146.6 360 134.1 360 119.9C360 97.8 342.1 79.9 320 79.9C297.9 79.9 280 97.8 280 119.9C280 134.1 287.4 146.6 298.5 153.7L221.1 269.8L126.8 225.4C127.5 222.4 127.9 219.2 127.9 215.9C127.9 193.8 110 175.9 87.9 175.9C65.8 175.9 47.9 193.8 47.9 215.9C47.9 237.5 65 255.1 86.5 255.9L128.1 505.2L129.2 511.9L510.8 511.9L511.9 505.2L553.5 255.9C574.9 255.1 592.1 237.5 592.1 215.9C592.1 193.8 574.2 175.9 552.1 175.9C530 175.9 512.1 193.8 512.1 215.9C512.1 219.2 512.5 222.3 513.2 225.4L418.9 269.8L341.5 153.7zM64 216C64 202.7 74.7 192 88 192C101.3 192 112 202.7 112 216C112 229.3 101.3 240 88 240C74.7 240 64 229.3 64 216zM552 192C565.3 192 576 202.7 576 216C576 229.3 565.3 240 552 240C538.7 240 528 229.3 528 216C528 202.7 538.7 192 552 192z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M259.4 3.9L240 18L220.8 3.7L259.4 3.9zM288 576C368.1 576 428.8 553.4 469.5 513.5C510.2 473.7 528 419.4 528 362.1C528 334.6 518 289.1 501.9 241.3C485.6 192.9 462 139 433.1 94.8L395.2 91.9L351.5 140.4C328.1 103.8 307.3 72.7 291.4 49.4C281.6 35 273.6 23.7 268.1 15.9C265.3 12 263.2 9 261.7 7L259.6 4.1L259.5 3.9L259.5 3.9L259.5 3.9L259.5 3.9L240.1 18C220.8 3.7 220.8 3.7 220.7 3.7L220.7 3.7L220.7 3.7L220.6 3.8L220.2 4.3L218.7 6.3C217.4 8 215.6 10.6 213.2 13.8C208.5 20.3 201.9 29.7 193.9 41.4C161.9 88.2 108.9 170.3 76 249C59.9 287.7 47.9 327.6 47.9 362.1C47.9 419.4 65.7 473.6 106.4 513.5C147.1 553.4 207.8 576 287.9 576zM426.3 417.4C422.4 404.3 416.9 391.4 410.6 379.2C398.5 355.5 382.5 332.2 367 312C351.4 291.7 336 274 324.4 261.4C318.6 255.1 313.7 250 310.3 246.5C308.6 244.7 307.2 243.4 306.3 242.4L305.2 241.3L304.9 241L304.8 240.9L304.8 240.9L304.8 240.9L288 258C271.2 240.9 271.2 240.9 271.2 240.9L271.2 240.9L271.2 240.9L271.1 241L270.8 241.3L269.7 242.4C268.8 243.3 267.4 244.7 265.7 246.5C262.3 250 257.4 255.1 251.6 261.4C240 274 224.5 291.7 209 312C193.5 332.2 177.6 355.5 165.4 379.2C159.2 391.4 153.6 404.3 149.7 417.4C145.9 400.4 144 381.9 144 362.1C144 335.9 150.7 302.6 161.7 266.1C183.4 194.1 220.5 114.9 242.7 70.8L244.9 66.5C247.1 69.6 249.3 72.9 251.7 76.4C271.1 104.7 297.9 144.9 327.7 192.6L365.9 196L400.8 157.3C422.9 235.1 432.1 331.8 432.1 362.1C432.1 381.9 430.2 400.4 426.4 417.4zM288 258L304.8 240.9L271.2 240.9L288 258zM288 528C250 528 226.5 518.1 212.7 505.5C199.1 493.1 192 475.4 192 453.6C192 439.7 197.6 421.7 208.1 401.1C218.5 380.9 232.5 360.1 247 341.2C261.4 322.3 276 305.8 286.9 293.9L288 292.7L289.1 293.9C300 305.8 314.5 322.4 329 341.2C343.5 360.1 357.6 380.9 367.9 401.1C378.5 421.7 384 439.7 384 453.6C384 475.4 376.9 493 363.3 505.5C349.5 518.1 325.9 528 288 528z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112 140.5L224 112.5L224 205.7L112 230L112 140.4zM112 350.5L224 326.2L224 415.6C222.8 415.9 221.6 416.2 220.4 416.5L112 443.5L112 350.4zM240 218.6L257.8 214.7C277.5 210.4 297.9 211.2 317.2 216.8L382.9 236.1C388.6 237.8 394.3 239.1 400.1 240.2L400.1 327.9C395.8 327 391.6 326 387.4 324.8L321.7 305.5C299.8 299.1 276.7 298.2 254.4 303.1L240 306.2L240 218.6zM416 140.6C443.7 140.6 471.3 134.2 496.7 121.5L528 105.9L528 209.5L462 223.6C446.8 226.9 431.3 227.7 416 226.3L416 140.5zM416 346.5C432.5 347.9 449.1 346.9 465.4 343.4L528 330L528 421L482.9 437.9C461.3 446 438.5 449.3 416 447.9L416 346.5z"/><path fill="currentColor" d="M112 72L112 64L96 64L96 576L112 576L112 460L224.3 431.9C265.4 421.6 308.9 426.4 346.8 445.3C391 467.4 442.3 470.1 488.5 452.7L544 432L544 80C537.1 83.5 518.9 92.5 489.6 107.2C443.3 130.4 388.8 130.4 342.5 107.2C307.4 89.6 267.1 85.2 229 94.7L112 124L112 72zM112 140.5L224 112.5L224 205.7L112 230L112 140.4zM112 246.5L224 222.2L224 309.8L112 334.1L112 246.5zM240 218.7L257.8 214.8C277.5 210.5 297.9 211.3 317.2 216.9L382.9 236.2C388.6 237.9 394.3 239.2 400.1 240.3L400.1 328C395.8 327.1 391.6 326.1 387.4 324.9L321.7 305.6C299.8 299.2 276.7 298.3 254.4 303.2L240 306.3L240 218.7zM416 242.5C432.5 243.9 449.1 242.9 465.4 239.4L528 226L528 313.6L462 327.7C446.8 331 431.3 331.9 416 330.4L416 242.5zM400 344.3L400 446C384.1 443.4 368.6 438.4 353.9 431.1C318.6 413.4 278.8 407.1 240 412.6L240 322.7L257.8 318.8C277.5 314.5 297.9 315.3 317.2 320.9L382.9 340.2C388.6 341.9 394.3 343.2 400.1 344.3zM416 447.8L416 346.4C432.5 347.8 449.1 346.8 465.4 343.3L528 329.9L528 420.9L482.9 437.8C461.3 445.9 438.5 449.2 416 447.8zM416 226.4L416 140.6C443.7 140.6 471.3 134.2 496.7 121.5L528 105.9L528 209.5L462 223.6C446.8 226.9 431.3 227.7 416 226.3zM400 139.9L400 224C395.7 223.1 391.5 222.1 387.3 220.9L321.6 201.6C299.7 195.2 276.6 194.3 254.3 199.2L239.9 202.3L239.9 108.7C272.1 102.3 305.7 106.8 335.2 121.5C355.7 131.8 377.7 137.9 399.9 139.8zM112 350.4L224 326.1L224 415.5C222.8 415.8 221.6 416.1 220.4 416.4L112 443.5L112 350.4z"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M80 558.2C80 547.9 80.5 533.8 82.3 518.4C86.1 486.6 95.2 453.4 113.7 434.9C138.9 409.7 179.8 409.7 205 434.9C230.2 460.1 230.2 501 205 526.2C186.4 544.8 153.3 553.8 121.5 557.6C106.1 559.4 92 559.9 81.7 559.9L79.9 559.9L79.9 558.1zM92.3 336L169.1 208L273.3 208C255.2 242.4 239.6 284.5 227.6 336L92.3 336zM241.5 346.9C273.3 203.8 332.3 135.8 391.8 104.7C448.7 75 509.3 77.2 554.9 85.1C562.8 130.7 565 191.3 535.3 248.2C504.2 307.7 436.3 366.7 293.1 398.5L241.4 346.8zM304 412.5C355.5 400.5 397.6 384.9 432 366.8L432 471L304 547.8L304 412.5zM400 192C400 218.5 421.5 240 448 240C474.5 240 496 218.5 496 192C496 165.5 474.5 144 448 144C421.5 144 400 165.5 400 192z"/><path fill="currentColor" d="M241.5 346.9C273.3 203.8 332.3 135.8 391.8 104.7C448.7 75 509.3 77.2 554.9 85.1C562.8 130.7 565 191.3 535.3 248.2C504.2 307.7 436.3 366.7 293.1 398.5L241.4 346.8zM568.6 71.4C494.1 56.5 365.6 50.6 282.1 192L160 192L73.6 336L64 352L224 352L288 416L288 576L304 566.4L448 480L448 357.9C589.4 274.4 583.5 145.9 568.6 71.4zM227.5 336L92.2 336L169 208L273.2 208C255.1 242.4 239.5 284.5 227.5 336zM304 547.7L304 412.4C355.5 400.4 397.6 384.8 432 366.7L432 470.9L304 547.7zM416 192C416 174.3 430.3 160 448 160C465.7 160 480 174.3 480 192C480 209.7 465.7 224 448 224C430.3 224 416 209.7 416 192zM496 192C496 165.5 474.5 144 448 144C421.5 144 400 165.5 400 192C400 218.5 421.5 240 448 240C474.5 240 496 218.5 496 192zM205.1 526.3C186.5 544.9 153.4 553.9 121.6 557.7C106.2 559.5 92.1 560 81.8 560L80 560L80 558.2C80 547.9 80.5 533.8 82.3 518.4C86.1 486.6 95.2 453.4 113.7 434.9C138.9 409.7 179.8 409.7 205 434.9C230.2 460.1 230.2 501 205 526.2zM216.4 537.6C247.9 506.1 247.9 455.1 216.4 423.6C184.9 392.1 133.9 392.1 102.4 423.6C57.1 468.9 64.4 575.6 64.4 575.6C64.4 575.6 171.1 582.9 216.4 537.6z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M96.6 177.9C93.8 225.7 101.3 299.4 132.6 371.5C164 443.9 219.8 515.1 313.7 555.8L320.1 558.5L326.5 555.8C420.4 515.2 476.2 443.9 507.6 371.6C538.9 299.5 546.4 225.7 543.6 178L543 167.7L533.4 164L325.9 83.5L320.1 81.3L314.3 83.5L106.8 164L97.2 167.7L96.6 178z"/><path fill="currentColor" d="M320 81.2L314.2 83.4L106.7 163.9L97.1 167.6L96.5 177.9C93.7 225.7 101.2 299.4 132.5 371.5C163.9 443.9 219.7 515.1 313.6 555.8L320 558.5L326.4 555.8C420.3 515.2 476.1 443.9 507.5 371.6C538.8 299.5 546.3 225.7 543.5 178L542.9 167.7L533.3 164L325.8 83.5L320 81.3zM558.3 156.5L559.5 177C562.4 227 554.6 303.3 522.2 377.9C489.5 453.2 431.1 527.9 332.8 570.5L320.1 576L307.4 570.5C208.9 527.9 150.6 453.2 117.9 377.9C85.5 303.3 77.7 226.9 80.6 177L81.8 156.5L101 149L308.5 68.5L320 64L331.6 68.5L539.1 149L558.3 156.5z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M55.4 320L166.5 369.4L172.1 371.9L174.6 377.5L224 488.6L273.4 377.5L275.9 371.9L281.5 369.4L392.6 320L281.5 270.6L275.9 268.1L273.4 262.5L224 151.4L174.6 262.5L172.1 268.1L166.5 270.6L55.4 320z"/><path fill="currentColor" d="M536 72L536 136L600 136L600 152L536 152L536 216L520 216L520 152L456 152L456 136L520 136L520 72L536 72zM504 424L504 488L568 488L568 504L504 504L504 568L488 568L488 504L424 504L424 488L488 488L488 424L504 424zM412.3 328.8L288 384L232.8 508.3L224 528L215.2 508.3L160 384L35.7 328.8L16 320L35.7 311.2L160 256L215.2 131.7L224 112L232.8 131.7L288 256L412.3 311.2L432 320L412.3 328.8zM273.4 377.5L275.9 371.9L281.5 369.4L392.6 320L281.5 270.6L275.9 268.1L273.4 262.5L224 151.4L174.6 262.5L172.1 268.1L166.5 270.6L55.4 320L166.5 369.4L172.1 371.9L174.6 377.5L224 488.6L273.4 377.5z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M310 13.3L267 13.3L192 164.2L21.4 186.2L7.5 227L124.4 343.9L85 535.7L120.9 561.1L288.5 460.1L456.1 561.1L492 535.7L452.6 343.9L569.5 227L555.6 186.2L385 164.2L310.1 13.3zM242.2 212.9L288.4 91.5L334.6 212.9L340.3 227.9L356.3 228.4L486.3 232.1L387.4 311.3L374.6 321.6L379.5 337.3L423.6 477.8L302.7 388.8L288.5 378.3L274.3 388.8L153.4 477.8L197.5 337.3L202.4 321.6L189.6 311.3L90.7 232.1L220.7 228.4L236.7 227.9L242.4 212.9z"/></svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M48.9 576L431.2 576L372.5 400L107.6 400L48.9 576zM128 192L128 208C128 269.9 178.1 320 240 320C301.9 320 352 269.9 352 208L352 192L128 192z"/><path fill="currentColor" d="M240 54L244.8 57.6L304.6 102.4C345.5 79.1 366.6 67 368 66.2L368 208C368 278.7 310.7 336 240 336C169.3 336 112 278.7 112 208L112 66.2C113.4 67 134.5 79.1 175.4 102.4L235.2 57.6L240 54zM240 74L180.8 118.4L176.6 121.6L172 119L128 93.8L128 176L352 176L352 93.8L308 119L303.4 121.6L299.2 118.4L240 74zM128 208C128 269.9 178.1 320 240 320C301.9 320 352 269.9 352 208L352 192L128 192L128 208zM416 129.3L416 113.1C421.2 112.4 426.6 112 432 112C493.9 112 544 162.1 544 224C544 285.9 493.9 336 432 336C411.3 336 391.9 330.4 375.3 320.6C378.7 316.5 382 312.2 385.1 307.7C399 315.5 415 320 432.1 320C485.1 320 528.1 277 528.1 224C528.1 171 485.1 128 432.1 128C426.6 128 421.3 128.5 416.1 129.3zM107.5 400L48.8 576L31.9 576L95.9 384L383.9 384L447.9 576L431 576L372.3 400L107.4 400zM439.9 400L434.6 384L544 384L608 576L591.1 576L532.4 400L439.9 400z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,6 +2,32 @@
<span class="nav-icon">{{ icon_svg(name)|safe }}</span>
{%- endmacro %}
{% macro badge_chip(user_badge) -%}
<span class="earned-badge">
<span class="earned-badge__icon">{{ nav_icon(user_badge.badge_definition.icon_name) }}</span>
<span>{{ user_badge.badge_definition.name }}</span>
</span>
{%- endmacro %}
{% macro badge_card(badge, earned=false, awarded_at=None) -%}
<article class="badge-card {% if earned %}badge-card--earned{% endif %}">
<div class="badge-card__icon">
{{ nav_icon(badge.icon_name) }}
</div>
<div class="badge-card__body">
<strong>{{ badge.name }}</strong>
<p class="muted">{{ badge.description }}</p>
<div class="chip-row">
<span class="point-pill">Bonus {{ badge.bonus_points }}</span>
<span class="reward-chip">Schwelle {{ badge.threshold }}</span>
{% if awarded_at %}
<span class="reward-chip">Freigeschaltet {{ awarded_at|date_de }}</span>
{% endif %}
</div>
</div>
</article>
{%- endmacro %}
{% macro status_badge(task) -%}
<span class="status-badge status-badge--{{ task.status }}">{{ task.status_label }}</span>
{%- endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "partials/macros.html" import avatar %}
{% from "partials/macros.html" import avatar, badge_chip %}
{% block title %}Highscoreboard · Putzliga{% endblock %}
{% block page_title %}Highscoreboard{% endblock %}
{% block content %}
@@ -50,7 +50,14 @@
{% if row.badges %}
<div class="badge-cloud">
{% for badge in row.badges %}
<span class="reward-chip">{{ badge.definition.name }} +{{ badge.bonus_points }}</span>
{{ badge_chip(badge) }}
{% endfor %}
</div>
{% endif %}
{% if row.user.awarded_badges %}
<div class="badge-cloud">
{% for badge in row.user.awarded_badges[:3] %}
{{ badge_chip(badge) }}
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% from "partials/macros.html" import badge_card, nav_icon %}
{% block title %}Badge-Regeln · Putzliga{% endblock %}
{% block page_title %}Badge-Regeln{% endblock %}
{% block content %}
<section class="settings-tabs">
{% for endpoint, label, icon in settings_tabs %}
<a href="{{ url_for(endpoint) }}" class="settings-tab {% if active_settings_tab == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }}
<span>{{ label }}</span>
</a>
{% endfor %}
</section>
<section class="panel">
<p class="eyebrow">Admin-Bereich</p>
<h2>Badges konfigurieren</h2>
<p class="muted">Die Icons stammen aus `heinz.marketing` und wurden für Putzliga lokal übernommen. Schwelle, Bonus und Aktiv-Status kannst du hier steuern.</p>
<div class="badge-settings">
{% for badge in badges %}
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ badge_card(badge) }}
<div class="field field--compact">
<label>Schwelle</label>
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
</div>
<div class="field field--compact">
<label>Bonus</label>
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
</div>
<label class="checkbox checkbox--compact">
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
<span>Aktiv</span>
</label>
<button type="submit" class="button button--secondary">Badge speichern</button>
</form>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -1,8 +1,17 @@
{% extends "base.html" %}
{% from "partials/macros.html" import avatar, nav_icon %}
{% from "partials/macros.html" import avatar, badge_chip, nav_icon %}
{% block title %}Optionen · Putzliga{% endblock %}
{% block page_title %}Optionen{% endblock %}
{% block content %}
<section class="settings-tabs">
{% for endpoint, label, icon in settings_tabs %}
<a href="{{ url_for(endpoint) }}" class="settings-tab {% if active_settings_tab == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }}
<span>{{ label }}</span>
</a>
{% endfor %}
</section>
<section class="two-column">
<article class="panel">
<p class="eyebrow">Profil & Benachrichtigungen</p>
@@ -70,35 +79,19 @@
</section>
<section class="panel">
<p class="eyebrow">Gamification</p>
<h2>Badge-Regeln pflegen</h2>
{% if current_user.is_admin %}
<div class="badge-settings">
{% for badge in badges %}
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<strong>{{ badge.name }}</strong>
<p class="muted">{{ badge.description }}</p>
</div>
<div class="field field--compact">
<label>Schwelle</label>
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
</div>
<div class="field field--compact">
<label>Bonus</label>
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
</div>
<label class="checkbox checkbox--compact">
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
<span>Aktiv</span>
</label>
<button type="submit" class="button button--secondary">Badge speichern</button>
</form>
<p class="eyebrow">Deine Trophäenwand</p>
<h2>Freigeschaltete Badges</h2>
{% if earned_badges %}
<div class="earned-badges-grid">
{% for badge in earned_badges %}
{{ badge_chip(badge) }}
{% endfor %}
</div>
{% else %}
<p class="muted">Badge-Regeln können nur von einem Admin geändert werden.</p>
<p class="muted">Noch keine Badges freigeschaltet. Die ersten kommen schnell, sobald Aufgaben erledigt werden.</p>
{% endif %}
{% if current_user.is_admin %}
<p class="inline-note">Badge-Regeln verwaltest du auf der separaten <a href="{{ url_for('settings.badges') }}">Badge-Seite</a>.</p>
{% endif %}
</section>