mirror of
https://github.com/Michatec/MiniFaceBook.git
synced 2026-05-30 18:02:40 +02:00
Compare commits
6 Commits
b376311cff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 27eadc840d | |||
| 2d4d48c45c | |||
| 5c2d882b45 | |||
| 5febf7e64d | |||
| 7f8948bba9 | |||
| 2b93ae73ff |
@@ -0,0 +1,3 @@
|
||||
# You can change this file, to your preferred Settings.
|
||||
|
||||
PORT=YOUR PORT
|
||||
+2
-3
@@ -10,11 +10,10 @@ migrations
|
||||
*.env
|
||||
.vscode
|
||||
routes/__pycache__
|
||||
tools
|
||||
*.pot
|
||||
*.mo
|
||||
routes/oauth.py
|
||||
static/profile_pics
|
||||
static/uploads
|
||||
commands.txt
|
||||
py-to-exemfc.json
|
||||
venv/
|
||||
.env
|
||||
@@ -30,15 +30,24 @@ MiniFacebook is a minimalist social network built with [Flask](https://flask.pal
|
||||
2. **Install dependencies**
|
||||
|
||||
```sh
|
||||
pip install -r requirments.txt
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Start**
|
||||
3. **Setup the .env file**
|
||||
|
||||
```sh
|
||||
mv .env_example .env
|
||||
```
|
||||
|
||||
And you can change this file, to your preferred Settings.
|
||||
⚠️ You need to set up a PORT
|
||||
|
||||
4. **Start**
|
||||
|
||||
```sh
|
||||
python main.py
|
||||
```
|
||||
4. **Optional:**
|
||||
5. **Optional:**
|
||||
|
||||
Go to routes/example_oauth.py
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify
|
||||
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify, current_app, session, has_request_context
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash
|
||||
@@ -20,8 +20,14 @@ try:
|
||||
from routes.oauth import oauth
|
||||
except ImportError:
|
||||
pass
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import os, sys
|
||||
|
||||
logger = logging.getLogger('waitress')
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(logging.StreamHandler(sys.stdout))
|
||||
|
||||
__mapper_args__ = {"confirm_deleted_rows": False}
|
||||
|
||||
@@ -66,40 +72,12 @@ app.register_blueprint(friends_bp)
|
||||
app.register_blueprint(noti_bp)
|
||||
app.register_blueprint(credits_bp)
|
||||
|
||||
with app.app_context():
|
||||
if db.session.query(ShopItem).count() == 0:
|
||||
db.session.add(ShopItem(
|
||||
name="Premium Account",
|
||||
description="Exclusive features and content.",
|
||||
price=100,
|
||||
icon="bi-star"
|
||||
))
|
||||
db.session.add(ShopItem(
|
||||
name="Gold Profile Frame",
|
||||
description="Adds a golden profile frame to your profile.",
|
||||
price=50,
|
||||
icon="bi-person-bounding-box"
|
||||
))
|
||||
db.session.add(ShopItem(
|
||||
name="Extra Upload Slot",
|
||||
description="Become able to upload more files.",
|
||||
price=130,
|
||||
icon="bi-cloud-upload"
|
||||
))
|
||||
db.session.add(ShopItem(
|
||||
name="More Types",
|
||||
description="More types for your posts. Limit: 500 types per post.",
|
||||
price=80,
|
||||
icon="bi-megaphone"
|
||||
))
|
||||
db.session.commit()
|
||||
else:
|
||||
pass
|
||||
|
||||
def get_locale():
|
||||
lang = request.cookies.get('lang')
|
||||
if lang in ['de', 'en']:
|
||||
return lang
|
||||
if has_request_context():
|
||||
lang = request.cookies.get('lang')
|
||||
if lang in ['de', 'en']:
|
||||
return lang
|
||||
return None
|
||||
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
@@ -188,23 +166,48 @@ def setup():
|
||||
@app.route('/shop', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def shop():
|
||||
items = db.session.query(ShopItem).all()
|
||||
message = None
|
||||
if 'shop_csrf_token' not in session:
|
||||
session['shop_csrf_token'] = os.urandom(24).hex()
|
||||
csrf_token_value = session['shop_csrf_token']
|
||||
|
||||
items = db.session.query(ShopItem).order_by(ShopItem.price.asc()).all()
|
||||
owned_ids = [usi.item_id for usi in current_user.shop_items]
|
||||
user_points = current_user.reward_points()
|
||||
|
||||
if request.method == 'POST':
|
||||
item_id = int(request.form['item_id'])
|
||||
if request.form.get('csrf_token') != session.get('shop_csrf_token'):
|
||||
logger.warning(f"CSRF token mismatch for user {current_user.id} in shop")
|
||||
abort(403)
|
||||
|
||||
try:
|
||||
item_id = int(request.form['item_id'])
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid item selected.'), 'danger')
|
||||
return redirect(url_for('shop'))
|
||||
|
||||
item = db.session.get(ShopItem, item_id)
|
||||
if item_id in owned_ids:
|
||||
message = _("Already purchased!")
|
||||
elif item and current_user.reward_points() >= item.price:
|
||||
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
|
||||
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
|
||||
db.session.commit()
|
||||
message = _(f"Purchased: {item.name}")
|
||||
owned_ids.append(item_id)
|
||||
|
||||
if not item:
|
||||
flash(_('Item not found.'), 'danger')
|
||||
elif item_id in owned_ids:
|
||||
flash(_('Already purchased!'), 'warning')
|
||||
elif user_points < item.price:
|
||||
flash(_('Not enough points! You need %(needed)d more.', needed=item.price - user_points), 'danger')
|
||||
else:
|
||||
message = _("Not enough points!")
|
||||
return render_template('shop.html', items=items, message=message, owned_ids=owned_ids)
|
||||
try:
|
||||
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
|
||||
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
|
||||
db.session.commit()
|
||||
session['shop_csrf_token'] = os.urandom(24).hex()
|
||||
flash(_('Successfully purchased: %(item_name)s', item_name=item.name), 'success')
|
||||
return redirect(url_for('shop'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Shop purchase failed for user {current_user.id}: {e}")
|
||||
flash(_('Purchase failed. Please try again.'), 'danger')
|
||||
|
||||
return render_template('shop.html', items=items, owned_ids=owned_ids,
|
||||
user_points=user_points, csrf_token_value=csrf_token_value)
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
@@ -217,8 +220,85 @@ def not_found(error):
|
||||
return redirect(url_for('post.feed'))
|
||||
return render_template('index.html'), 200
|
||||
|
||||
def print_error(message):
|
||||
print(f"\033[91m[ERROR] {message}\033[0m")
|
||||
|
||||
def print_loading(message):
|
||||
colors = ["\033[91m", "\033[93m", "\033[92m", "\033[96m", "\033[94m", "\033[95m"]
|
||||
color = colors[hash(message) % len(colors)]
|
||||
print(f"{color}◆ {message}...\033[0m")
|
||||
|
||||
|
||||
def print_success(message):
|
||||
colors = ["\033[92m", "\033[96m", "\033[94m", "\033[95m"]
|
||||
color = colors[hash(message) % len(colors)]
|
||||
print(f"{color}✓ {message}\033[0m")
|
||||
|
||||
def print_rainbow_separator():
|
||||
rainbow = "\033[91m▆\033[93m▆\033[92m▆\033[96m▆\033[94m▆\033[95m▆\033[0m"
|
||||
print(f" {rainbow * 12}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print_loading("Starting MiniFaceBook...")
|
||||
print_rainbow_separator()
|
||||
print_loading("Initializing database")
|
||||
try:
|
||||
serve(app, host="0.0.0.0", port=80, threads=12)
|
||||
with app.app_context():
|
||||
if db.session.query(ShopItem).count() == 0:
|
||||
shop_items = [
|
||||
{
|
||||
'name': _('Premium Account'),
|
||||
'description': _('Exclusive features and content.'),
|
||||
'price': 100,
|
||||
'icon': 'bi-star'
|
||||
},
|
||||
{
|
||||
'name': _('Gold Profile Frame'),
|
||||
'description': _('Adds a golden profile frame to your profile.'),
|
||||
'price': 50,
|
||||
'icon': 'bi-person-bounding-box'
|
||||
},
|
||||
{
|
||||
'name': _('Extra Upload Slot'),
|
||||
'description': _('Become able to upload more files.'),
|
||||
'price': 130,
|
||||
'icon': 'bi-cloud-upload'
|
||||
},
|
||||
{
|
||||
'name': _('More Types'),
|
||||
'description': _('More types for your posts. Limit: 500 types per post.'),
|
||||
'price': 80,
|
||||
'icon': 'bi-megaphone'
|
||||
}
|
||||
]
|
||||
|
||||
for item_data in shop_items:
|
||||
item = ShopItem(**item_data)
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
print_success("Database initialized successfully.")
|
||||
except Exception as e:
|
||||
print_error(f"Database initialization failed: {e}")
|
||||
print_error("Please check your database configuration and ensure the database is accessible.")
|
||||
sys.exit(1)
|
||||
|
||||
print_loading("Loading environment variables")
|
||||
try:
|
||||
load_dotenv()
|
||||
port = os.environ.get('PORT')
|
||||
print_success("Environment variables loaded successfully.")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to load environment variables: {e}")
|
||||
print_error("Please set the environment variables!")
|
||||
sys.exit(1)
|
||||
|
||||
print_loading(f"Using port {port}")
|
||||
print_rainbow_separator()
|
||||
print_loading("Starting server with Waitress")
|
||||
|
||||
try:
|
||||
print_success(f"Server started successfully with Waitress at the port {port}.")
|
||||
serve(app, host="0.0.0.0", port=port, threads=12, connection_limit=1000)
|
||||
except:
|
||||
app.run(debug=True, host="0.0.0.0", port=80)
|
||||
print_error(f"Failed to start with Waitress, falling back to Flask's built-in server at port {port}. This is not recommended for production use.")
|
||||
app.run(debug=True, host="0.0.0.0", port=port)
|
||||
@@ -119,6 +119,8 @@ class UserShopItem(db.Model):
|
||||
item_id = db.Column(db.Integer, db.ForeignKey('shop_item.id'))
|
||||
bought_at = db.Column(db.DateTime, default=datetime.now)
|
||||
item = db.relationship('ShopItem')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'item_id', name='unique_user_item_purchase'),)
|
||||
|
||||
class SupportRequest(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
@@ -7,3 +7,4 @@ waitress
|
||||
authlib
|
||||
sqlalchemy
|
||||
requests
|
||||
dotenv
|
||||
+89
-13
@@ -5,6 +5,16 @@ body {
|
||||
cursor: url("/static/icons/custom-cursor.png"), auto;
|
||||
}
|
||||
|
||||
body *::selection {
|
||||
cursor: text;
|
||||
}
|
||||
input::selection, textarea::selection {
|
||||
cursor: text;
|
||||
}
|
||||
body input, body textarea {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
canvas {
|
||||
background: black;
|
||||
border: 1px solid white;
|
||||
@@ -14,12 +24,31 @@ canvas {
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
background: #fff;
|
||||
border: none;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
color: #2563eb !important;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Navbar in dark mode */
|
||||
body.dark-mode .navbar {
|
||||
background: #1f2937 !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
body.dark-mode .navbar .nav-link {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
body.dark-mode .navbar .nav-link:hover {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
body.dark-mode .navbar-toggler {
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
body.dark-mode .navbar-toggler-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(229,231,235,0.9)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.profile-pic {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -120,29 +149,65 @@ canvas {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Light mode scrollbar */
|
||||
body.light-mode ::-webkit-scrollbar {
|
||||
background: #d1d5db;
|
||||
}
|
||||
body.light-mode ::-webkit-scrollbar-thumb {
|
||||
background: #9ca3af;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Light Mode (default) */
|
||||
body, .card, .navbar, .list-group-item, .table, .form-control, .form-select {
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
body.light-mode {
|
||||
background: linear-gradient(135deg, #0f5b86 0%, #0245aabb 100%);
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e8ecf0 100%);
|
||||
color: #222;
|
||||
}
|
||||
body.light-mode .card,
|
||||
body.light-mode .navbar,
|
||||
body.light-mode .list-group-item,
|
||||
body.light-mode .table {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
body.light-mode .navbar {
|
||||
background: #f8fafc !important;
|
||||
border-color: #e5e7eb !important;
|
||||
color: #222;
|
||||
}
|
||||
body.light-mode .navbar .nav-link {
|
||||
color: #374151 !important;
|
||||
}
|
||||
body.light-mode .navbar .nav-link:hover {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
body.light-mode .form-control,
|
||||
body.light-mode .form-select {
|
||||
background: #f8fafc;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
body.light-mode .form-control:focus,
|
||||
body.light-mode .form-select:focus {
|
||||
background: #fff;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px #2563eb22;
|
||||
}
|
||||
|
||||
body.light-mode li button {
|
||||
color: #0011ff !important;
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
/* Links in light mode */
|
||||
body.light-mode a,
|
||||
body.light-mode a:visited {
|
||||
color: #2563eb !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
body.light-mode a:hover {
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
@@ -155,9 +220,6 @@ body.dark-mode li button {
|
||||
color: #0099f1 !important;
|
||||
}
|
||||
|
||||
body.light-mode p {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card,
|
||||
body.dark-mode .navbar,
|
||||
@@ -177,9 +239,14 @@ body.dark-mode .form-control:focus,
|
||||
body.dark-mode .form-select:focus {
|
||||
border-color: #2563eb !important;
|
||||
box-shadow: 0 0 0 2px #2563eb55 !important;
|
||||
background: #23272f !important;
|
||||
background: #1f2937 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
body.light-mode .form-control:focus,
|
||||
body.light-mode .form-select:focus {
|
||||
background: #fff !important;
|
||||
}
|
||||
body.dark-mode .btn,
|
||||
body.dark-mode .btn-primary,
|
||||
body.dark-mode .btn-success,
|
||||
@@ -226,10 +293,10 @@ body.dark-mode .profile-pic {
|
||||
body.dark-mode .table th,
|
||||
body.dark-mode .table td {
|
||||
color: #e5e7eb !important;
|
||||
background: #23272f !important;
|
||||
background: #1f2937 !important;
|
||||
}
|
||||
body.dark-mode .list-group-item {
|
||||
background: #23272f !important;
|
||||
background: #1f2937 !important;
|
||||
color: #e5e7eb !important;
|
||||
box-shadow: 0 1px 4px rgba(37,99,235,0.08) !important;
|
||||
}
|
||||
@@ -271,17 +338,26 @@ body.dark-mode .navbar-toggler-icon {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
body.light-mode .footer {
|
||||
background: #f8fafc !important;
|
||||
border-top: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
body.dark-mode .footer {
|
||||
background: #181a1b !important;
|
||||
border-top: 1px solid #23272f !important;
|
||||
background: #1f2937 !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
|
||||
.footer .text-muted, .footer a {
|
||||
color: #6c757d !important;
|
||||
}
|
||||
|
||||
body.light-mode .footer .text-muted, body.light-mode .footer a {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .footer .text-muted, body.dark-mode .footer a {
|
||||
color: #b0b8c1 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100 {{ theme_class }}">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<nav class="navbar navbar-expand-lg bg-light" id="main-navbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">
|
||||
<i class="bi bi-people-fill me-2"></i>MiniFacebook
|
||||
|
||||
+110
-26
@@ -1,33 +1,117 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Shop') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
|
||||
<p>{{ _('Deine Reward-Punkte:') }} <b>{{ current_user.reward_points() }}</b></p>
|
||||
{% if message %}
|
||||
<div class="alert alert-info">{{ message }}</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% for item in items %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi {{ item.icon }} display-4 mb-2"></i>
|
||||
<h5>{{ item.name }}</h5>
|
||||
<p>{{ item.description }}</p>
|
||||
<p><b>{{ item.price }}</b> {{ _('Points') }}</p>
|
||||
{% if item.id in owned_ids %}
|
||||
<button class="btn btn-secondary" disabled>{{ _('Bought') }}</button>
|
||||
{% else %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button class="btn btn-success" {% if current_user.reward_points() < item.price %}disabled{% endif %}>
|
||||
<i class="bi bi-cart"></i> {{ _('Buy') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="container-fluid px-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="points-display bg-gradient-primary text-white px-4 py-2 rounded-3 shadow-sm">
|
||||
<i class="bi bi-coin me-2"></i>
|
||||
<span>{{ _('Your Points:') }}</span>
|
||||
<strong class="ms-1">{{ user_points }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="row g-4">
|
||||
{% for item in items %}
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card h-100 shadow-sm border-0 {% if item.id in owned_ids %}owned-bg{% endif %}">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="text-center mb-3">
|
||||
<div class="icon-wrapper text-white rounded-circle mx-auto mb-2 d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||
<i class="bi {{ item.icon }} display-4"></i>
|
||||
</div>
|
||||
<h5 class="card-title mb-1">{{ item.name }}</h5>
|
||||
<small class="text-muted">{{ item.description }}</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="price-tag">
|
||||
<span class="h4 text-primary mb-0">{{ item.price }}</span>
|
||||
<small class="text-muted">{{ _('Points') }}</small>
|
||||
</div>
|
||||
{% if item.id in owned_ids %}
|
||||
<span class="badge bg-success rounded-pill px-3 py-2">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ _('Owned') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.id in owned_ids %}
|
||||
<button class="btn btn-outline-secondary w-100" disabled>
|
||||
<i class="bi bi-bag-check me-2"></i>{{ _('Already Purchased') }}
|
||||
</button>
|
||||
{% else %}
|
||||
{% if user_points >= item.price %}
|
||||
<form method="post" class="d-grid">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token_value }}">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="bi bi-cart-plus me-2"></i>{{ _('Buy Now') }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-secondary w-100" disabled title="{{ _('Not enough points') }}">
|
||||
<i class="bi bi-coin me-2"></i>{{ _('Need %(needed)d more points', needed=item.price - user_points) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not items %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted mb-3"></i>
|
||||
<h4>{{ _('No items available') }}</h4>
|
||||
<p class="text-muted">{{ _('Check back later for new items in the shop!') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.points-display {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-wrapper:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
|
||||
.owned-bg {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid #28a745 !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.price-tag .h4 {
|
||||
color: #0d6efd;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user