Upload 21 files
Browse files- __pycache__/forms.cpython-314.pyc +0 -0
- __pycache__/models.cpython-314.pyc +0 -0
- app.py +327 -0
- forms.py +35 -0
- instance/ghostboard.db +0 -0
- models.py +40 -0
- requirements.txt +5 -0
- static/images/icon.svg +4 -0
- static/manifest.json +27 -0
- static/sw.js +35 -0
- templates/admin.html +87 -0
- templates/admin_boards.html +68 -0
- templates/admin_login.html +29 -0
- templates/base.html +321 -0
- templates/board.html +151 -0
- templates/index.html +18 -0
- templates/thread.html +346 -0
- verification/before_changes.png +0 -0
- verification/capture_before.py +39 -0
- verification/thread_view.png +0 -0
- verification/verify_comments.py +52 -0
__pycache__/forms.cpython-314.pyc
ADDED
|
Binary file (2.12 kB). View file
|
|
|
__pycache__/models.cpython-314.pyc
ADDED
|
Binary file (3.84 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, redirect, url_for, abort, flash, session, send_from_directory
|
| 2 |
+
from werkzeug.utils import secure_filename
|
| 3 |
+
from werkzeug.security import check_password_hash, generate_password_hash
|
| 4 |
+
from models import db, Board, Post, BannedIP, PostFile
|
| 5 |
+
from forms import PostForm
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
import os
|
| 8 |
+
import uuid
|
| 9 |
+
import re
|
| 10 |
+
from markupsafe import Markup
|
| 11 |
+
from functools import wraps
|
| 12 |
+
|
| 13 |
+
app = Flask(__name__)
|
| 14 |
+
app.config['SECRET_KEY'] = 'dev-secret-key' # Change in production
|
| 15 |
+
os.makedirs(app.instance_path, exist_ok=True)
|
| 16 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(app.instance_path, 'ghostboard.db')
|
| 17 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 18 |
+
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static/uploads')
|
| 19 |
+
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
| 20 |
+
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024 # 20 MB limit
|
| 21 |
+
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'mp4', 'webm'}
|
| 22 |
+
app.config['ADMIN_PASSWORD_HASH'] = generate_password_hash('admin') # Default password: admin
|
| 23 |
+
|
| 24 |
+
db.init_app(app)
|
| 25 |
+
|
| 26 |
+
def init_db():
|
| 27 |
+
db.create_all()
|
| 28 |
+
|
| 29 |
+
# Seed default boards
|
| 30 |
+
boards = [
|
| 31 |
+
{'slug': 'g', 'name': 'Général', 'description': 'Discussion générale'},
|
| 32 |
+
{'slug': 'tech', 'name': 'Technologie', 'description': 'Ordinateurs, programmation, gadgets'},
|
| 33 |
+
{'slug': 'art', 'name': 'Création', 'description': 'Art, design, musique'},
|
| 34 |
+
{'slug': 'news', 'name': 'Actualités', 'description': 'Événements actuels'}
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
for b_data in boards:
|
| 38 |
+
if not Board.query.filter_by(slug=b_data['slug']).first():
|
| 39 |
+
board = Board(slug=b_data['slug'], name=b_data['name'], description=b_data['description'])
|
| 40 |
+
db.session.add(board)
|
| 41 |
+
|
| 42 |
+
db.session.commit()
|
| 43 |
+
|
| 44 |
+
# Auto-initialize database if it doesn't exist
|
| 45 |
+
with app.app_context():
|
| 46 |
+
if not os.path.exists(os.path.join(app.instance_path, 'ghostboard.db')):
|
| 47 |
+
init_db()
|
| 48 |
+
|
| 49 |
+
def allowed_file(filename):
|
| 50 |
+
return '.' in filename and \
|
| 51 |
+
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
|
| 52 |
+
|
| 53 |
+
def save_file(file):
|
| 54 |
+
if file and allowed_file(file.filename):
|
| 55 |
+
original_filename = secure_filename(file.filename)
|
| 56 |
+
extension = original_filename.rsplit('.', 1)[1].lower()
|
| 57 |
+
new_filename = f"{uuid.uuid4().hex}.{extension}"
|
| 58 |
+
file.save(os.path.join(app.config['UPLOAD_FOLDER'], new_filename))
|
| 59 |
+
return new_filename, original_filename
|
| 60 |
+
return None, None
|
| 61 |
+
|
| 62 |
+
def delete_post_files(post):
|
| 63 |
+
for file in post.files:
|
| 64 |
+
path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
|
| 65 |
+
if os.path.exists(path):
|
| 66 |
+
os.remove(path)
|
| 67 |
+
db.session.delete(file)
|
| 68 |
+
|
| 69 |
+
def delete_thread_content(thread):
|
| 70 |
+
# Delete all replies
|
| 71 |
+
replies = Post.query.filter_by(thread_id=thread.id).all()
|
| 72 |
+
for reply in replies:
|
| 73 |
+
delete_post_files(reply)
|
| 74 |
+
db.session.delete(reply)
|
| 75 |
+
|
| 76 |
+
delete_post_files(thread)
|
| 77 |
+
db.session.delete(thread)
|
| 78 |
+
|
| 79 |
+
def prune_board(board_id):
|
| 80 |
+
# Limit to 100 threads
|
| 81 |
+
threads_query = Post.query.filter_by(board_id=board_id, thread_id=None).order_by(Post.updated_at.desc())
|
| 82 |
+
if threads_query.count() > 100:
|
| 83 |
+
threads_to_delete = threads_query.offset(100).all()
|
| 84 |
+
for thread in threads_to_delete:
|
| 85 |
+
delete_thread_content(thread)
|
| 86 |
+
db.session.commit()
|
| 87 |
+
|
| 88 |
+
def admin_required(f):
|
| 89 |
+
@wraps(f)
|
| 90 |
+
def decorated_function(*args, **kwargs):
|
| 91 |
+
if not session.get('is_admin'):
|
| 92 |
+
return redirect(url_for('admin_login', next=request.url))
|
| 93 |
+
return f(*args, **kwargs)
|
| 94 |
+
return decorated_function
|
| 95 |
+
|
| 96 |
+
def check_ban():
|
| 97 |
+
ip = request.remote_addr
|
| 98 |
+
ban = BannedIP.query.filter_by(ip_address=ip).first()
|
| 99 |
+
if ban:
|
| 100 |
+
abort(403, description=f"Vous êtes banni. Raison : {ban.reason}")
|
| 101 |
+
|
| 102 |
+
@app.cli.command("init-db")
|
| 103 |
+
def init_db_command():
|
| 104 |
+
"""Create database tables and seed initial data."""
|
| 105 |
+
with app.app_context():
|
| 106 |
+
init_db()
|
| 107 |
+
print("Base de données initialisée.")
|
| 108 |
+
|
| 109 |
+
@app.context_processor
|
| 110 |
+
def inject_boards():
|
| 111 |
+
return dict(global_boards=Board.query.all())
|
| 112 |
+
|
| 113 |
+
@app.template_filter('format_post')
|
| 114 |
+
def format_post(content):
|
| 115 |
+
if not content:
|
| 116 |
+
return ""
|
| 117 |
+
|
| 118 |
+
# Escape HTML first (safety)
|
| 119 |
+
content = str(Markup.escape(content))
|
| 120 |
+
|
| 121 |
+
# Link to quotes >>12345
|
| 122 |
+
def replace_quote(match):
|
| 123 |
+
post_id = match.group(1)
|
| 124 |
+
return f'<a href="#post-{post_id}" class="text-blue-400 hover:underline" onclick="highlightPost({post_id})">>>{post_id}</a>'
|
| 125 |
+
|
| 126 |
+
content = re.sub(r'>>(\d+)', replace_quote, content)
|
| 127 |
+
|
| 128 |
+
# Greentext (lines starting with >)
|
| 129 |
+
content = re.sub(r'^(>.*)$', r'<span class="text-green-400">\1</span>', content, flags=re.MULTILINE)
|
| 130 |
+
|
| 131 |
+
# Convert newlines to <br>
|
| 132 |
+
content = content.replace('\n', '<br>')
|
| 133 |
+
|
| 134 |
+
return Markup(content)
|
| 135 |
+
|
| 136 |
+
@app.route('/manifest.json')
|
| 137 |
+
def manifest():
|
| 138 |
+
return send_from_directory('static', 'manifest.json')
|
| 139 |
+
|
| 140 |
+
@app.route('/sw.js')
|
| 141 |
+
def service_worker():
|
| 142 |
+
return send_from_directory('static', 'sw.js')
|
| 143 |
+
|
| 144 |
+
@app.route('/')
|
| 145 |
+
def index():
|
| 146 |
+
boards = Board.query.all()
|
| 147 |
+
return render_template('index.html', boards=boards)
|
| 148 |
+
|
| 149 |
+
@app.route('/<slug>/', methods=['GET', 'POST'])
|
| 150 |
+
def board(slug):
|
| 151 |
+
board = Board.query.filter_by(slug=slug).first_or_404()
|
| 152 |
+
form = PostForm()
|
| 153 |
+
|
| 154 |
+
if form.validate_on_submit():
|
| 155 |
+
check_ban()
|
| 156 |
+
|
| 157 |
+
# Create new thread
|
| 158 |
+
post = Post(
|
| 159 |
+
board_id=board.id,
|
| 160 |
+
content=form.content.data,
|
| 161 |
+
nickname=form.nickname.data or 'Anonyme',
|
| 162 |
+
ip_address=request.remote_addr
|
| 163 |
+
)
|
| 164 |
+
db.session.add(post)
|
| 165 |
+
db.session.commit()
|
| 166 |
+
|
| 167 |
+
# Handle multiple files
|
| 168 |
+
if form.files.data:
|
| 169 |
+
for f in form.files.data:
|
| 170 |
+
filename, original_filename = save_file(f)
|
| 171 |
+
if filename:
|
| 172 |
+
post_file = PostFile(
|
| 173 |
+
post_id=post.id,
|
| 174 |
+
filename=filename,
|
| 175 |
+
original_filename=original_filename
|
| 176 |
+
)
|
| 177 |
+
db.session.add(post_file)
|
| 178 |
+
db.session.commit()
|
| 179 |
+
|
| 180 |
+
# Prune board
|
| 181 |
+
prune_board(board.id)
|
| 182 |
+
|
| 183 |
+
return redirect(url_for('board', slug=slug))
|
| 184 |
+
|
| 185 |
+
# Get threads (posts with no thread_id), sorted by updated_at (bumping)
|
| 186 |
+
threads = Post.query.filter_by(board_id=board.id, thread_id=None)\
|
| 187 |
+
.order_by(Post.updated_at.desc()).limit(50).all()
|
| 188 |
+
return render_template('board.html', board=board, threads=threads, form=form)
|
| 189 |
+
|
| 190 |
+
@app.route('/thread/<int:thread_id>/', methods=['GET', 'POST'])
|
| 191 |
+
def thread(thread_id):
|
| 192 |
+
thread_post = Post.query.get_or_404(thread_id)
|
| 193 |
+
if thread_post.thread_id is not None:
|
| 194 |
+
# If it's a reply, redirect to the main thread
|
| 195 |
+
return redirect(url_for('thread', thread_id=thread_post.thread_id))
|
| 196 |
+
|
| 197 |
+
form = PostForm()
|
| 198 |
+
if form.validate_on_submit():
|
| 199 |
+
check_ban()
|
| 200 |
+
|
| 201 |
+
# Create reply
|
| 202 |
+
reply = Post(
|
| 203 |
+
board_id=thread_post.board_id,
|
| 204 |
+
thread_id=thread_post.id,
|
| 205 |
+
content=form.content.data,
|
| 206 |
+
nickname=form.nickname.data or 'Anonyme',
|
| 207 |
+
ip_address=request.remote_addr
|
| 208 |
+
)
|
| 209 |
+
db.session.add(reply)
|
| 210 |
+
|
| 211 |
+
# Handle multiple files
|
| 212 |
+
if form.files.data:
|
| 213 |
+
for f in form.files.data:
|
| 214 |
+
filename, original_filename = save_file(f)
|
| 215 |
+
if filename:
|
| 216 |
+
post_file = PostFile(
|
| 217 |
+
post_id=reply.id,
|
| 218 |
+
filename=filename,
|
| 219 |
+
original_filename=original_filename
|
| 220 |
+
)
|
| 221 |
+
db.session.add(post_file)
|
| 222 |
+
|
| 223 |
+
# Bump the thread
|
| 224 |
+
thread_post.updated_at = datetime.utcnow()
|
| 225 |
+
|
| 226 |
+
db.session.commit()
|
| 227 |
+
return redirect(url_for('thread', thread_id=thread_post.id))
|
| 228 |
+
|
| 229 |
+
replies = Post.query.filter_by(thread_id=thread_post.id).order_by(Post.created_at.asc()).all()
|
| 230 |
+
return render_template('thread.html', thread=thread_post, replies=replies, board=thread_post.board, form=form)
|
| 231 |
+
|
| 232 |
+
# Admin Routes
|
| 233 |
+
@app.route('/admin/login', methods=['GET', 'POST'])
|
| 234 |
+
def admin_login():
|
| 235 |
+
if request.method == 'POST':
|
| 236 |
+
password = request.form.get('password')
|
| 237 |
+
if check_password_hash(app.config['ADMIN_PASSWORD_HASH'], password):
|
| 238 |
+
session['is_admin'] = True
|
| 239 |
+
return redirect(url_for('admin_dashboard'))
|
| 240 |
+
else:
|
| 241 |
+
flash('Mot de passe invalide')
|
| 242 |
+
return render_template('admin_login.html')
|
| 243 |
+
|
| 244 |
+
@app.route('/admin/logout')
|
| 245 |
+
def admin_logout():
|
| 246 |
+
session.pop('is_admin', None)
|
| 247 |
+
return redirect(url_for('index'))
|
| 248 |
+
|
| 249 |
+
@app.route('/admin')
|
| 250 |
+
@admin_required
|
| 251 |
+
def admin_dashboard():
|
| 252 |
+
# Show recent posts (both threads and replies) for moderation
|
| 253 |
+
recent_posts = Post.query.order_by(Post.created_at.desc()).limit(100).all()
|
| 254 |
+
return render_template('admin.html', posts=recent_posts)
|
| 255 |
+
|
| 256 |
+
@app.route('/admin/delete/<int:post_id>', methods=['POST'])
|
| 257 |
+
@admin_required
|
| 258 |
+
def delete_post(post_id):
|
| 259 |
+
post = Post.query.get_or_404(post_id)
|
| 260 |
+
|
| 261 |
+
if post.thread_id is None:
|
| 262 |
+
# It's a thread starter, delete whole thread
|
| 263 |
+
delete_thread_content(post)
|
| 264 |
+
else:
|
| 265 |
+
# It's a reply
|
| 266 |
+
delete_post_file(post)
|
| 267 |
+
db.session.delete(post)
|
| 268 |
+
|
| 269 |
+
db.session.commit()
|
| 270 |
+
flash('Post supprimé')
|
| 271 |
+
return redirect(request.referrer or url_for('admin_dashboard'))
|
| 272 |
+
|
| 273 |
+
@app.route('/admin/ban/<int:post_id>', methods=['POST'])
|
| 274 |
+
@admin_required
|
| 275 |
+
def ban_ip(post_id):
|
| 276 |
+
post = Post.query.get_or_404(post_id)
|
| 277 |
+
if not BannedIP.query.filter_by(ip_address=post.ip_address).first():
|
| 278 |
+
ban = BannedIP(ip_address=post.ip_address, reason="Banni par l'administrateur")
|
| 279 |
+
db.session.add(ban)
|
| 280 |
+
db.session.commit()
|
| 281 |
+
flash(f'IP {post.ip_address} bannie')
|
| 282 |
+
else:
|
| 283 |
+
flash(f'L\'IP {post.ip_address} est déjà bannie')
|
| 284 |
+
|
| 285 |
+
return redirect(request.referrer or url_for('admin_dashboard'))
|
| 286 |
+
|
| 287 |
+
@app.route('/admin/boards')
|
| 288 |
+
@admin_required
|
| 289 |
+
def admin_boards():
|
| 290 |
+
boards = Board.query.all()
|
| 291 |
+
return render_template('admin_boards.html', boards=boards)
|
| 292 |
+
|
| 293 |
+
@app.route('/admin/boards/add', methods=['POST'])
|
| 294 |
+
@admin_required
|
| 295 |
+
def admin_add_board():
|
| 296 |
+
slug = request.form.get('slug')
|
| 297 |
+
name = request.form.get('name')
|
| 298 |
+
description = request.form.get('description')
|
| 299 |
+
|
| 300 |
+
if slug and name:
|
| 301 |
+
if not Board.query.filter_by(slug=slug).first():
|
| 302 |
+
board = Board(slug=slug, name=name, description=description)
|
| 303 |
+
db.session.add(board)
|
| 304 |
+
db.session.commit()
|
| 305 |
+
flash('Planche ajoutée')
|
| 306 |
+
else:
|
| 307 |
+
flash('Ce slug existe déjà')
|
| 308 |
+
else:
|
| 309 |
+
flash('Données manquantes')
|
| 310 |
+
return redirect(url_for('admin_boards'))
|
| 311 |
+
|
| 312 |
+
@app.route('/admin/boards/delete/<int:board_id>', methods=['POST'])
|
| 313 |
+
@admin_required
|
| 314 |
+
def admin_delete_board(board_id):
|
| 315 |
+
board = Board.query.get_or_404(board_id)
|
| 316 |
+
# Delete all posts in board
|
| 317 |
+
posts = Post.query.filter_by(board_id=board.id, thread_id=None).all()
|
| 318 |
+
for post in posts:
|
| 319 |
+
delete_thread_content(post)
|
| 320 |
+
|
| 321 |
+
db.session.delete(board)
|
| 322 |
+
db.session.commit()
|
| 323 |
+
flash('Planche supprimée')
|
| 324 |
+
return redirect(url_for('admin_boards'))
|
| 325 |
+
|
| 326 |
+
if __name__ == '__main__':
|
| 327 |
+
app.run(debug=True)
|
forms.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_wtf import FlaskForm
|
| 2 |
+
from wtforms import TextAreaField, MultipleFileField, StringField, SubmitField
|
| 3 |
+
from wtforms.validators import Optional, Length
|
| 4 |
+
|
| 5 |
+
class PostForm(FlaskForm):
|
| 6 |
+
nickname = StringField('Pseudo', validators=[Optional(), Length(max=50)])
|
| 7 |
+
content = TextAreaField('Message', validators=[Optional(), Length(max=2000)])
|
| 8 |
+
files = MultipleFileField('Fichiers', validators=[Optional()])
|
| 9 |
+
honeypot = StringField('Site Web', validators=[Optional()]) # Anti-spam field
|
| 10 |
+
submit = SubmitField('Envoyer')
|
| 11 |
+
|
| 12 |
+
def validate(self, extra_validators=None):
|
| 13 |
+
initial_validation = super(PostForm, self).validate(extra_validators)
|
| 14 |
+
if not initial_validation:
|
| 15 |
+
return False
|
| 16 |
+
|
| 17 |
+
# Custom validation: Must have either content or files
|
| 18 |
+
if not self.content.data and not self.files.data:
|
| 19 |
+
# Check if files list is empty or contains empty file
|
| 20 |
+
has_files = False
|
| 21 |
+
if self.files.data:
|
| 22 |
+
for f in self.files.data:
|
| 23 |
+
if f.filename:
|
| 24 |
+
has_files = True
|
| 25 |
+
break
|
| 26 |
+
|
| 27 |
+
if not has_files and not self.content.data:
|
| 28 |
+
self.content.errors.append('Vous devez fournir un message ou un fichier.')
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
# Honeypot check
|
| 32 |
+
if self.honeypot.data:
|
| 33 |
+
return False
|
| 34 |
+
|
| 35 |
+
return True
|
instance/ghostboard.db
ADDED
|
Binary file (28.7 kB). View file
|
|
|
models.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
db = SQLAlchemy()
|
| 5 |
+
|
| 6 |
+
class Board(db.Model):
|
| 7 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 8 |
+
slug = db.Column(db.String(10), unique=True, nullable=False)
|
| 9 |
+
name = db.Column(db.String(50), nullable=False)
|
| 10 |
+
description = db.Column(db.String(200))
|
| 11 |
+
posts = db.relationship('Post', backref='board', lazy=True)
|
| 12 |
+
|
| 13 |
+
class PostFile(db.Model):
|
| 14 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 15 |
+
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
|
| 16 |
+
filename = db.Column(db.String(100), nullable=False)
|
| 17 |
+
original_filename = db.Column(db.String(100), nullable=False)
|
| 18 |
+
|
| 19 |
+
class Post(db.Model):
|
| 20 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 21 |
+
thread_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) # If null, it's a thread starter
|
| 22 |
+
board_id = db.Column(db.Integer, db.ForeignKey('board.id'), nullable=False)
|
| 23 |
+
content = db.Column(db.Text, nullable=True)
|
| 24 |
+
nickname = db.Column(db.String(50), default='Anonyme')
|
| 25 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 26 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow) # For bumping
|
| 27 |
+
ip_address = db.Column(db.String(50), nullable=True)
|
| 28 |
+
|
| 29 |
+
files = db.relationship('PostFile', backref='post', lazy=True)
|
| 30 |
+
|
| 31 |
+
# Relationship to access replies easily
|
| 32 |
+
replies = db.relationship('Post',
|
| 33 |
+
backref=db.backref('parent', remote_side=[id]),
|
| 34 |
+
lazy=True)
|
| 35 |
+
|
| 36 |
+
class BannedIP(db.Model):
|
| 37 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 38 |
+
ip_address = db.Column(db.String(50), unique=True, nullable=False)
|
| 39 |
+
reason = db.Column(db.String(200))
|
| 40 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask
|
| 2 |
+
Flask-SQLAlchemy
|
| 3 |
+
Flask-WTF
|
| 4 |
+
email_validator
|
| 5 |
+
Werkzeug
|
static/images/icon.svg
ADDED
|
|
static/manifest.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "GhostBoard",
|
| 3 |
+
"short_name": "GhostBoard",
|
| 4 |
+
"description": "Anonymous Imageboard",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#ffffff",
|
| 8 |
+
"theme_color": "#ffffff",
|
| 9 |
+
"icons": [
|
| 10 |
+
{
|
| 11 |
+
"src": "/static/images/icon.svg",
|
| 12 |
+
"sizes": "any",
|
| 13 |
+
"type": "image/svg+xml",
|
| 14 |
+
"purpose": "any maskable"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"src": "/static/images/icon.svg",
|
| 18 |
+
"sizes": "192x192",
|
| 19 |
+
"type": "image/svg+xml"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"src": "/static/images/icon.svg",
|
| 23 |
+
"sizes": "512x512",
|
| 24 |
+
"type": "image/svg+xml"
|
| 25 |
+
}
|
| 26 |
+
]
|
| 27 |
+
}
|
static/sw.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const CACHE_NAME = 'ghostboard-v1';
|
| 2 |
+
const ASSETS = [
|
| 3 |
+
'/',
|
| 4 |
+
'/static/images/icon.svg',
|
| 5 |
+
'/static/manifest.json',
|
| 6 |
+
'https://cdn.tailwindcss.com',
|
| 7 |
+
'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
self.addEventListener('install', (e) => {
|
| 11 |
+
e.waitUntil(
|
| 12 |
+
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
|
| 13 |
+
);
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
self.addEventListener('fetch', (e) => {
|
| 17 |
+
// Network first for HTML (navigation), Cache first for assets
|
| 18 |
+
if (e.request.mode === 'navigate') {
|
| 19 |
+
e.respondWith(
|
| 20 |
+
fetch(e.request).catch(() => {
|
| 21 |
+
return caches.match(e.request).then(response => {
|
| 22 |
+
if (response) return response;
|
| 23 |
+
// Fallback to offline page if I had one, or just root
|
| 24 |
+
return caches.match('/');
|
| 25 |
+
});
|
| 26 |
+
})
|
| 27 |
+
);
|
| 28 |
+
} else {
|
| 29 |
+
e.respondWith(
|
| 30 |
+
caches.match(e.request).then((response) => {
|
| 31 |
+
return response || fetch(e.request);
|
| 32 |
+
})
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
});
|
templates/admin.html
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Tableau de bord Admin{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="mb-8 flex justify-between items-center px-4">
|
| 7 |
+
<h1 class="text-3xl font-bold text-gray-900">Tableau de bord Admin</h1>
|
| 8 |
+
<div class="space-x-2">
|
| 9 |
+
<a href="{{ url_for('admin_boards') }}" class="bg-blue-100 hover:bg-blue-200 text-blue-800 px-4 py-2 rounded-lg font-medium">Gérer les Planches</a>
|
| 10 |
+
<a href="{{ url_for('admin_logout') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium">Déconnexion</a>
|
| 11 |
+
</div>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
{% with messages = get_flashed_messages() %}
|
| 15 |
+
{% if messages %}
|
| 16 |
+
<div class="px-4 mb-4">
|
| 17 |
+
{% for message in messages %}
|
| 18 |
+
<div class="bg-green-50 text-green-700 border border-green-200 p-3 rounded-lg">{{ message }}</div>
|
| 19 |
+
{% endfor %}
|
| 20 |
+
</div>
|
| 21 |
+
{% endif %}
|
| 22 |
+
{% endwith %}
|
| 23 |
+
|
| 24 |
+
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mx-4">
|
| 25 |
+
<div class="overflow-x-auto">
|
| 26 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 27 |
+
<thead class="bg-gray-50">
|
| 28 |
+
<tr>
|
| 29 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
| 30 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
| 31 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Board</th>
|
| 32 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contenu/Fichier</th>
|
| 33 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
|
| 34 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
| 35 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
| 36 |
+
</tr>
|
| 37 |
+
</thead>
|
| 38 |
+
<tbody class="bg-white divide-y divide-gray-200 text-gray-700">
|
| 39 |
+
{% for post in posts %}
|
| 40 |
+
<tr class="hover:bg-gray-50">
|
| 41 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 42 |
+
<a href="{{ url_for('thread', thread_id=post.thread_id if post.thread_id else post.id) }}#post-{{ post.id }}" target="_blank" class="text-blue-600 hover:underline font-medium">
|
| 43 |
+
{{ post.id }}
|
| 44 |
+
</a>
|
| 45 |
+
</td>
|
| 46 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 47 |
+
{% if post.thread_id %}
|
| 48 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Réponse</span>
|
| 49 |
+
{% else %}
|
| 50 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Fil</span>
|
| 51 |
+
{% endif %}
|
| 52 |
+
</td>
|
| 53 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 54 |
+
/{{ post.board.slug }}/
|
| 55 |
+
</td>
|
| 56 |
+
<td class="px-6 py-4">
|
| 57 |
+
<div class="max-w-xs truncate text-xs">
|
| 58 |
+
{{ post.content|default('', true) }}
|
| 59 |
+
{% if post.filename %}
|
| 60 |
+
<br>
|
| 61 |
+
<span class="text-blue-500">[Fichier : {{ post.original_filename }}]</span>
|
| 62 |
+
{% endif %}
|
| 63 |
+
</div>
|
| 64 |
+
</td>
|
| 65 |
+
<td class="px-6 py-4 whitespace-nowrap text-xs">
|
| 66 |
+
{{ post.ip_address }}
|
| 67 |
+
</td>
|
| 68 |
+
<td class="px-6 py-4 whitespace-nowrap text-xs">
|
| 69 |
+
{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}
|
| 70 |
+
</td>
|
| 71 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 72 |
+
<div class="flex space-x-2">
|
| 73 |
+
<form action="{{ url_for('delete_post', post_id=post.id) }}" method="POST" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer ce post ?');">
|
| 74 |
+
<button type="submit" class="text-red-600 hover:text-red-800 font-bold text-sm">Supprimer</button>
|
| 75 |
+
</form>
|
| 76 |
+
<form action="{{ url_for('ban_ip', post_id=post.id) }}" method="POST" onsubmit="return confirm('Êtes-vous sûr de vouloir BANNIR cette IP ?');">
|
| 77 |
+
<button type="submit" class="text-orange-600 hover:text-orange-800 font-bold text-sm">Bannir IP</button>
|
| 78 |
+
</form>
|
| 79 |
+
</div>
|
| 80 |
+
</td>
|
| 81 |
+
</tr>
|
| 82 |
+
{% endfor %}
|
| 83 |
+
</tbody>
|
| 84 |
+
</table>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
{% endblock %}
|
templates/admin_boards.html
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Gestion des Planches{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="mb-8 flex justify-between items-center px-4">
|
| 7 |
+
<h1 class="text-3xl font-bold text-gray-900">Gestion des Planches</h1>
|
| 8 |
+
<a href="{{ url_for('admin_dashboard') }}" class="text-blue-600 hover:underline">Retour au Dashboard</a>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
{% with messages = get_flashed_messages() %}
|
| 12 |
+
{% if messages %}
|
| 13 |
+
<div class="px-4 mb-4">
|
| 14 |
+
{% for message in messages %}
|
| 15 |
+
<div class="bg-green-50 text-green-700 border border-green-200 p-3 rounded-lg">{{ message }}</div>
|
| 16 |
+
{% endfor %}
|
| 17 |
+
</div>
|
| 18 |
+
{% endif %}
|
| 19 |
+
{% endwith %}
|
| 20 |
+
|
| 21 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4">
|
| 22 |
+
<!-- Add Board Form -->
|
| 23 |
+
<div class="bg-white p-6 rounded-xl border border-gray-100 shadow-sm h-fit">
|
| 24 |
+
<h2 class="text-xl font-bold text-gray-900 mb-4">Ajouter une planche</h2>
|
| 25 |
+
<form action="{{ url_for('admin_add_board') }}" method="POST" class="space-y-4">
|
| 26 |
+
<div>
|
| 27 |
+
<label class="block text-gray-700 text-sm font-semibold mb-2" for="slug">Slug (ex: tech)</label>
|
| 28 |
+
<input type="text" name="slug" id="slug" required class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-2 px-3 focus:ring-2 focus:ring-blue-500 placeholder-gray-400" placeholder="tech">
|
| 29 |
+
</div>
|
| 30 |
+
<div>
|
| 31 |
+
<label class="block text-gray-700 text-sm font-semibold mb-2" for="name">Nom (ex: Technologie)</label>
|
| 32 |
+
<input type="text" name="name" id="name" required class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-2 px-3 focus:ring-2 focus:ring-blue-500 placeholder-gray-400" placeholder="Technologie">
|
| 33 |
+
</div>
|
| 34 |
+
<div>
|
| 35 |
+
<label class="block text-gray-700 text-sm font-semibold mb-2" for="description">Description</label>
|
| 36 |
+
<input type="text" name="description" id="description" class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-2 px-3 focus:ring-2 focus:ring-blue-500 placeholder-gray-400" placeholder="Description courte">
|
| 37 |
+
</div>
|
| 38 |
+
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg transition-transform active:scale-95">Ajouter</button>
|
| 39 |
+
</form>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<!-- List Boards -->
|
| 43 |
+
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
| 44 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 45 |
+
<thead class="bg-gray-50">
|
| 46 |
+
<tr>
|
| 47 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
|
| 48 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
|
| 49 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Action</th>
|
| 50 |
+
</tr>
|
| 51 |
+
</thead>
|
| 52 |
+
<tbody class="divide-y divide-gray-200 text-gray-700">
|
| 53 |
+
{% for board in boards %}
|
| 54 |
+
<tr class="hover:bg-gray-50">
|
| 55 |
+
<td class="px-6 py-4 font-mono text-sm text-blue-600">/{{ board.slug }}/</td>
|
| 56 |
+
<td class="px-6 py-4 font-medium">{{ board.name }}</td>
|
| 57 |
+
<td class="px-6 py-4">
|
| 58 |
+
<form action="{{ url_for('admin_delete_board', board_id=board.id) }}" method="POST" onsubmit="return confirm('Supprimer cette planche et TOUS ses posts ?');">
|
| 59 |
+
<button type="submit" class="text-red-600 hover:text-red-800 font-bold text-sm">Supprimer</button>
|
| 60 |
+
</form>
|
| 61 |
+
</td>
|
| 62 |
+
</tr>
|
| 63 |
+
{% endfor %}
|
| 64 |
+
</tbody>
|
| 65 |
+
</table>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
{% endblock %}
|
templates/admin_login.html
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Connexion Admin{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="max-w-md mx-auto bg-white p-8 rounded-xl border border-gray-100 shadow-lg mt-12">
|
| 7 |
+
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Connexion Admin</h1>
|
| 8 |
+
|
| 9 |
+
{% with messages = get_flashed_messages() %}
|
| 10 |
+
{% if messages %}
|
| 11 |
+
{% for message in messages %}
|
| 12 |
+
<div class="bg-red-50 text-red-600 border border-red-200 p-3 rounded mb-4 text-center text-sm">{{ message }}</div>
|
| 13 |
+
{% endfor %}
|
| 14 |
+
{% endif %}
|
| 15 |
+
{% endwith %}
|
| 16 |
+
|
| 17 |
+
<form method="POST">
|
| 18 |
+
<div class="mb-6">
|
| 19 |
+
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">Mot de passe</label>
|
| 20 |
+
<input class="w-full bg-gray-50 text-gray-900 border border-gray-300 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" type="password" name="password" id="password" required>
|
| 21 |
+
</div>
|
| 22 |
+
<div>
|
| 23 |
+
<button class="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-transform active:scale-95" type="submit">
|
| 24 |
+
Connexion
|
| 25 |
+
</button>
|
| 26 |
+
</div>
|
| 27 |
+
</form>
|
| 28 |
+
</div>
|
| 29 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<link rel="manifest" href="/manifest.json">
|
| 7 |
+
<meta name="theme-color" content="#ffffff">
|
| 8 |
+
<link rel="apple-touch-icon" href="/static/images/icon.svg">
|
| 9 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 10 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
| 11 |
+
<title>{% block title %}GhostBoard{% endblock %}</title>
|
| 12 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 13 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
| 14 |
+
<style>
|
| 15 |
+
body {
|
| 16 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 17 |
+
-webkit-font-smoothing: antialiased;
|
| 18 |
+
}
|
| 19 |
+
</style>
|
| 20 |
+
</head>
|
| 21 |
+
<body class="bg-gray-50 text-gray-900 min-h-screen pb-20"> <!-- pb-20 for FAB space -->
|
| 22 |
+
<nav class="bg-white border-b border-gray-200 sticky top-0 z-50 bg-opacity-90 backdrop-blur-sm">
|
| 23 |
+
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
| 24 |
+
<a href="{{ url_for('index') }}" class="text-xl font-bold text-gray-900 tracking-tight">GhostBoard</a>
|
| 25 |
+
<div class="flex space-x-3 text-sm font-medium overflow-x-auto no-scrollbar">
|
| 26 |
+
{% for board in global_boards %}
|
| 27 |
+
<a href="{{ url_for('board', slug=board.slug) }}" class="text-gray-600 hover:text-blue-600 whitespace-nowrap">/{{ board.slug }}/</a>
|
| 28 |
+
{% endfor %}
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</nav>
|
| 32 |
+
|
| 33 |
+
<main class="container mx-auto p-4">
|
| 34 |
+
{% block content %}{% endblock %}
|
| 35 |
+
</main>
|
| 36 |
+
|
| 37 |
+
<!-- Share App Button -->
|
| 38 |
+
<div class="container mx-auto px-4 mt-8 mb-4">
|
| 39 |
+
<button onclick="shareAppOnWhatsApp()" class="w-full sm:w-auto mx-auto block bg-[#25D366] hover:bg-[#128C7E] text-white font-bold py-4 px-8 rounded-full shadow-lg transform transition hover:scale-105 flex items-center justify-center space-x-3">
|
| 40 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
|
| 41 |
+
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.008-.57-.008-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 1.032 5.122 3.022 6.989 5.615.119 5.269 2.768 9.825 8.526 9.881-.14 0-.28 0-.42-.01l-.001-.001z"/>
|
| 42 |
+
</svg>
|
| 43 |
+
<span class="text-xl">Partager l'application</span>
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<footer class="text-center text-gray-400 text-xs py-8 mt-8">
|
| 48 |
+
<p>GhostBoard © 2025</p>
|
| 49 |
+
</footer>
|
| 50 |
+
|
| 51 |
+
<!-- Share Modal -->
|
| 52 |
+
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-40 backdrop-blur-sm p-4">
|
| 53 |
+
<div class="absolute inset-0" onclick="closeShareModal()"></div>
|
| 54 |
+
<div class="bg-white w-full max-w-sm rounded-2xl shadow-2xl transform transition-all relative z-10 overflow-hidden">
|
| 55 |
+
<div class="p-6 text-center">
|
| 56 |
+
<h3 class="text-xl font-bold text-gray-900 mb-6">Partager ce post</h3>
|
| 57 |
+
<div class="space-y-3">
|
| 58 |
+
<button onclick="copyCurrentLink()" class="w-full bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-4 rounded-xl flex items-center justify-center transition-colors">
|
| 59 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-3 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 60 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
| 61 |
+
</svg>
|
| 62 |
+
Copier le lien
|
| 63 |
+
</button>
|
| 64 |
+
<button onclick="shareToWhatsApp()" class="w-full bg-[#25D366] hover:bg-[#128C7E] text-white font-semibold py-3 px-4 rounded-xl flex items-center justify-center transition-colors">
|
| 65 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-3" viewBox="0 0 24 24" fill="currentColor">
|
| 66 |
+
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.008-.57-.008-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 1.032 5.122 3.022 6.989 5.615.119 5.269 2.768 9.825 8.526 9.881-.14 0-.28 0-.42-.01l-.001-.001z"/>
|
| 67 |
+
</svg>
|
| 68 |
+
Partager sur WhatsApp
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
+
<button onclick="closeShareModal()" class="mt-6 text-gray-500 hover:text-gray-700 text-sm font-medium">Fermer</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<script>
|
| 77 |
+
function shareAppOnWhatsApp() {
|
| 78 |
+
const url = window.location.origin;
|
| 79 |
+
const text = "Découvre GhostBoard, c'est génial ! " + url;
|
| 80 |
+
const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
| 81 |
+
window.open(whatsappUrl, '_blank');
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
let currentShareId = null;
|
| 85 |
+
let currentShareUrl = null;
|
| 86 |
+
|
| 87 |
+
function replyTo(id) {
|
| 88 |
+
const textarea = document.getElementById('content');
|
| 89 |
+
const formContainer = document.getElementById('post-form-container');
|
| 90 |
+
|
| 91 |
+
if (formContainer && formContainer.classList.contains('hidden')) {
|
| 92 |
+
togglePostForm();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if (textarea) {
|
| 96 |
+
const currentVal = textarea.value;
|
| 97 |
+
textarea.value = currentVal + (currentVal ? '\n' : '') + '>>' + id + '\n';
|
| 98 |
+
textarea.focus();
|
| 99 |
+
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function togglePostForm() {
|
| 104 |
+
const container = document.getElementById('post-form-container');
|
| 105 |
+
const fab = document.getElementById('fab-button');
|
| 106 |
+
if (container.classList.contains('hidden')) {
|
| 107 |
+
container.classList.remove('hidden');
|
| 108 |
+
// Focus textarea
|
| 109 |
+
setTimeout(() => document.getElementById('content').focus(), 100);
|
| 110 |
+
if(fab) fab.classList.add('hidden');
|
| 111 |
+
} else {
|
| 112 |
+
container.classList.add('hidden');
|
| 113 |
+
if(fab) fab.classList.remove('hidden');
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function highlightPost(id) {
|
| 118 |
+
const post = document.getElementById('post-' + id);
|
| 119 |
+
if (post) {
|
| 120 |
+
post.classList.add('bg-blue-50');
|
| 121 |
+
post.classList.add('ring-2');
|
| 122 |
+
post.classList.add('ring-blue-200');
|
| 123 |
+
setTimeout(() => {
|
| 124 |
+
post.classList.remove('bg-blue-50');
|
| 125 |
+
post.classList.remove('ring-2');
|
| 126 |
+
post.classList.remove('ring-blue-200');
|
| 127 |
+
}, 2000);
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function sharePost(id, url) {
|
| 132 |
+
currentShareId = id;
|
| 133 |
+
currentShareUrl = url;
|
| 134 |
+
|
| 135 |
+
const modal = document.getElementById('share-modal');
|
| 136 |
+
modal.classList.remove('hidden');
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function closeShareModal() {
|
| 140 |
+
document.getElementById('share-modal').classList.add('hidden');
|
| 141 |
+
currentShareId = null;
|
| 142 |
+
currentShareUrl = null;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
async function copyCurrentLink() {
|
| 146 |
+
if (!currentShareUrl) return;
|
| 147 |
+
try {
|
| 148 |
+
await navigator.clipboard.writeText(currentShareUrl);
|
| 149 |
+
alert('Lien copié dans le presse-papier !');
|
| 150 |
+
closeShareModal();
|
| 151 |
+
} catch (err) {
|
| 152 |
+
console.error('Failed to copy: ', err);
|
| 153 |
+
alert('Impossible de copier le lien');
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function shareToWhatsApp() {
|
| 158 |
+
if (!currentShareId || !currentShareUrl) return;
|
| 159 |
+
|
| 160 |
+
const id = currentShareId;
|
| 161 |
+
const url = currentShareUrl;
|
| 162 |
+
|
| 163 |
+
// Close modal immediately to avoid capturing it or blocking UI
|
| 164 |
+
closeShareModal();
|
| 165 |
+
|
| 166 |
+
// Notify user generating image
|
| 167 |
+
const toast = document.createElement('div');
|
| 168 |
+
toast.className = 'fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg z-50';
|
| 169 |
+
toast.textContent = 'Génération de l\'image...';
|
| 170 |
+
document.body.appendChild(toast);
|
| 171 |
+
|
| 172 |
+
const originalPost = document.getElementById('post-' + id);
|
| 173 |
+
if (!originalPost) {
|
| 174 |
+
toast.remove();
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// Wrapper for style (NGL like background)
|
| 179 |
+
const wrapper = document.createElement('div');
|
| 180 |
+
wrapper.style.width = '600px';
|
| 181 |
+
wrapper.style.padding = '40px';
|
| 182 |
+
wrapper.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
| 183 |
+
wrapper.style.display = 'flex';
|
| 184 |
+
wrapper.style.flexDirection = 'column';
|
| 185 |
+
wrapper.style.alignItems = 'center';
|
| 186 |
+
wrapper.style.justifyContent = 'center';
|
| 187 |
+
wrapper.style.position = 'absolute';
|
| 188 |
+
wrapper.style.top = '-9999px';
|
| 189 |
+
wrapper.style.left = '-9999px';
|
| 190 |
+
|
| 191 |
+
// Clone the post
|
| 192 |
+
const clone = originalPost.cloneNode(true);
|
| 193 |
+
|
| 194 |
+
// Remove actions/buttons from clone
|
| 195 |
+
const actions = clone.querySelectorAll('button, a[href^="#"], .no-capture');
|
| 196 |
+
actions.forEach(el => el.remove());
|
| 197 |
+
|
| 198 |
+
// Style clone card
|
| 199 |
+
clone.id = 'clone-capture';
|
| 200 |
+
clone.style.width = '100%';
|
| 201 |
+
clone.style.maxWidth = '100%';
|
| 202 |
+
clone.style.margin = '0';
|
| 203 |
+
clone.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
|
| 204 |
+
clone.style.borderRadius = '16px';
|
| 205 |
+
clone.style.boxShadow = '0 10px 25px rgba(0,0,0,0.2)';
|
| 206 |
+
clone.style.padding = '20px';
|
| 207 |
+
clone.style.position = 'relative';
|
| 208 |
+
clone.style.top = 'auto';
|
| 209 |
+
clone.style.left = 'auto';
|
| 210 |
+
|
| 211 |
+
// Add Branding
|
| 212 |
+
const branding = document.createElement('div');
|
| 213 |
+
branding.className = 'mt-4 pt-4 border-t border-gray-200 flex items-center justify-center text-gray-500 text-xs font-bold uppercase tracking-widest';
|
| 214 |
+
branding.innerHTML = 'GhostBoard';
|
| 215 |
+
|
| 216 |
+
const contentDiv = clone.querySelector('.flex-grow') || clone;
|
| 217 |
+
contentDiv.appendChild(branding);
|
| 218 |
+
|
| 219 |
+
wrapper.appendChild(clone);
|
| 220 |
+
document.body.appendChild(wrapper);
|
| 221 |
+
|
| 222 |
+
try {
|
| 223 |
+
const canvas = await html2canvas(wrapper, {
|
| 224 |
+
scale: 2,
|
| 225 |
+
backgroundColor: null,
|
| 226 |
+
useCORS: true,
|
| 227 |
+
logging: false
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
document.body.removeChild(wrapper);
|
| 231 |
+
toast.remove();
|
| 232 |
+
|
| 233 |
+
canvas.toBlob(async (blob) => {
|
| 234 |
+
if (!blob) return;
|
| 235 |
+
|
| 236 |
+
const file = new File([blob], `ghostboard-${id}.png`, { type: 'image/png' });
|
| 237 |
+
|
| 238 |
+
// Share data with text link
|
| 239 |
+
const shareData = {
|
| 240 |
+
files: [file],
|
| 241 |
+
title: 'GhostBoard',
|
| 242 |
+
text: url // Description text for WhatsApp
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
| 246 |
+
try {
|
| 247 |
+
await navigator.share(shareData);
|
| 248 |
+
} catch (err) {
|
| 249 |
+
console.log('Share dismissed or failed', err);
|
| 250 |
+
}
|
| 251 |
+
} else {
|
| 252 |
+
// Fallback download if web share not available (e.g. desktop)
|
| 253 |
+
const link = document.createElement('a');
|
| 254 |
+
link.download = `ghostboard-${id}.png`;
|
| 255 |
+
link.href = canvas.toDataURL();
|
| 256 |
+
link.click();
|
| 257 |
+
alert("Partage natif non supporté sur cet appareil. L'image a été téléchargée. Vous pouvez maintenant la partager sur WhatsApp manuellement avec le lien : " + url);
|
| 258 |
+
}
|
| 259 |
+
});
|
| 260 |
+
} catch (err) {
|
| 261 |
+
console.error('Capture failed', err);
|
| 262 |
+
if (wrapper.parentNode) document.body.removeChild(wrapper);
|
| 263 |
+
toast.remove();
|
| 264 |
+
alert("Impossible de générer l'image. Veuillez réessayer.");
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Image expansion logic
|
| 269 |
+
document.querySelectorAll('img.post-image').forEach(img => {
|
| 270 |
+
img.addEventListener('click', function(e) {
|
| 271 |
+
if (this.dataset.full) {
|
| 272 |
+
e.preventDefault();
|
| 273 |
+
if (this.src === this.dataset.thumb) {
|
| 274 |
+
this.src = this.dataset.full;
|
| 275 |
+
this.style.maxWidth = '100%';
|
| 276 |
+
this.style.maxHeight = 'none';
|
| 277 |
+
} else {
|
| 278 |
+
this.src = this.dataset.thumb;
|
| 279 |
+
this.style.maxWidth = ''; // Reset to class default
|
| 280 |
+
this.style.maxHeight = '';
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
});
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
// Register Service Worker
|
| 287 |
+
if ('serviceWorker' in navigator) {
|
| 288 |
+
window.addEventListener('load', () => {
|
| 289 |
+
navigator.serviceWorker.register('/sw.js')
|
| 290 |
+
.then(reg => console.log('SW Registered'))
|
| 291 |
+
.catch(err => console.log('SW Registration Failed', err));
|
| 292 |
+
});
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function previewFiles(input, previewId) {
|
| 296 |
+
const preview = document.getElementById(previewId);
|
| 297 |
+
if (!preview) return;
|
| 298 |
+
preview.innerHTML = '';
|
| 299 |
+
if (input.files) {
|
| 300 |
+
Array.from(input.files).forEach(file => {
|
| 301 |
+
const reader = new FileReader();
|
| 302 |
+
reader.onload = function(e) {
|
| 303 |
+
if (file.type.startsWith('image/')) {
|
| 304 |
+
const img = document.createElement('img');
|
| 305 |
+
img.src = e.target.result;
|
| 306 |
+
img.className = 'w-20 h-20 object-cover rounded shadow-sm';
|
| 307 |
+
preview.appendChild(img);
|
| 308 |
+
} else if (file.type.startsWith('video/')) {
|
| 309 |
+
const vid = document.createElement('video');
|
| 310 |
+
vid.src = e.target.result;
|
| 311 |
+
vid.className = 'w-20 h-20 object-cover rounded shadow-sm bg-black';
|
| 312 |
+
preview.appendChild(vid);
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
reader.readAsDataURL(file);
|
| 316 |
+
});
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
</script>
|
| 320 |
+
</body>
|
| 321 |
+
</html>
|
templates/board.html
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}/{{ board.slug }}/ - {{ board.name }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="mb-6 px-4 pt-4">
|
| 7 |
+
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">/{{ board.slug }}/ - {{ board.name }}</h1>
|
| 8 |
+
<p class="text-gray-500 text-sm mt-1">{{ board.description }}</p>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
<!-- Threads List -->
|
| 12 |
+
<div class="space-y-4 px-2 pb-24">
|
| 13 |
+
{% for thread in threads %}
|
| 14 |
+
<div class="bg-white p-4 rounded-xl border border-gray-100 shadow-sm" id="post-{{ thread.id }}">
|
| 15 |
+
<div class="flex gap-4">
|
| 16 |
+
<!-- Thumbnails -->
|
| 17 |
+
{% if thread.files %}
|
| 18 |
+
<div class="flex-shrink-0 flex flex-col gap-2">
|
| 19 |
+
{% for file in thread.files[:1] %} <!-- Show only first file as thumbnail -->
|
| 20 |
+
{% if file.filename.endswith(('.mp4', '.webm')) %}
|
| 21 |
+
<video controls class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg bg-black">
|
| 22 |
+
<source src="{{ url_for('static', filename='uploads/' + file.filename) }}" type="video/{{ file.filename.split('.')[-1] }}">
|
| 23 |
+
</video>
|
| 24 |
+
{% else %}
|
| 25 |
+
<img
|
| 26 |
+
src="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 27 |
+
data-thumb="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 28 |
+
data-full="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 29 |
+
alt="Post image"
|
| 30 |
+
class="post-image w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg cursor-pointer hover:opacity-90 transition-opacity bg-gray-100"
|
| 31 |
+
>
|
| 32 |
+
{% endif %}
|
| 33 |
+
{% endfor %}
|
| 34 |
+
{% if thread.files|length > 1 %}
|
| 35 |
+
<span class="text-xs text-gray-500 text-center">+ {{ thread.files|length - 1 }} autres</span>
|
| 36 |
+
{% endif %}
|
| 37 |
+
</div>
|
| 38 |
+
{% endif %}
|
| 39 |
+
|
| 40 |
+
<div class="flex-grow min-w-0"> <!-- min-w-0 for truncation -->
|
| 41 |
+
<div class="flex items-center text-xs text-gray-500 mb-2 space-x-2">
|
| 42 |
+
<span class="font-bold text-gray-900">{{ thread.nickname }}</span>
|
| 43 |
+
<span>{{ thread.created_at.strftime('%d/%m %H:%M') }}</span>
|
| 44 |
+
<a href="{{ url_for('thread', thread_id=thread.id) }}" class="text-blue-500 hover:underline">N° {{ thread.id }}</a>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="text-gray-800 text-base leading-relaxed break-words line-clamp-4 mb-3">
|
| 48 |
+
{{ thread.content|default('', true)|truncate(300)|format_post }}
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="flex items-center justify-between">
|
| 52 |
+
<a href="{{ url_for('thread', thread_id=thread.id) }}" class="text-sm font-medium text-blue-600 hover:text-blue-800 bg-blue-50 px-3 py-1 rounded-full">
|
| 53 |
+
Voir le fil
|
| 54 |
+
{% if thread.replies|length > 0 %}
|
| 55 |
+
<span class="ml-1 text-blue-800">({{ thread.replies|length }})</span>
|
| 56 |
+
{% endif %}
|
| 57 |
+
</a>
|
| 58 |
+
<button class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" onclick="sharePost('{{ thread.id }}', '{{ url_for('thread', thread_id=thread.id, _external=True) }}')" title="Partager">
|
| 59 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 60 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
| 61 |
+
</svg>
|
| 62 |
+
</button>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<!-- Latest replies preview (Desktop only maybe? Or simplified) -->
|
| 66 |
+
{% set recent_replies = thread.replies[-2:] %}
|
| 67 |
+
{% if recent_replies %}
|
| 68 |
+
<div class="mt-4 space-y-2 border-l-2 border-gray-100 pl-3">
|
| 69 |
+
{% for reply in recent_replies %}
|
| 70 |
+
<div class="text-sm text-gray-600">
|
| 71 |
+
<span class="text-xs text-gray-400">>> {{ reply.created_at.strftime('%H:%M') }}</span>
|
| 72 |
+
<span class="line-clamp-1">{{ reply.content|truncate(100) }}</span>
|
| 73 |
+
</div>
|
| 74 |
+
{% endfor %}
|
| 75 |
+
</div>
|
| 76 |
+
{% endif %}
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
{% else %}
|
| 81 |
+
<div class="text-center text-gray-400 py-12 bg-white rounded-xl border border-gray-100 border-dashed">
|
| 82 |
+
<p>C'est calme ici. Lancez une conversation.</p>
|
| 83 |
+
</div>
|
| 84 |
+
{% endfor %}
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<!-- Floating Action Button -->
|
| 88 |
+
<button id="fab-button" onclick="togglePostForm()" class="fixed bottom-6 right-6 bg-blue-600 hover:bg-blue-700 text-white rounded-full p-4 shadow-lg transition-transform hover:scale-105 active:scale-95 z-40 flex items-center justify-center w-14 h-14">
|
| 89 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 90 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
| 91 |
+
</svg>
|
| 92 |
+
</button>
|
| 93 |
+
|
| 94 |
+
<!-- Hidden Modal Form -->
|
| 95 |
+
<div id="post-form-container" class="hidden fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-gray-900 bg-opacity-40 backdrop-blur-sm p-0 sm:p-4">
|
| 96 |
+
<!-- Overlay click to close -->
|
| 97 |
+
<div class="absolute inset-0" onclick="togglePostForm()"></div>
|
| 98 |
+
|
| 99 |
+
<div class="bg-white w-full max-w-lg rounded-t-2xl sm:rounded-2xl shadow-2xl transform transition-all relative z-10 flex flex-col max-h-[90vh]">
|
| 100 |
+
<div class="flex justify-between items-center p-4 border-b border-gray-100">
|
| 101 |
+
<h3 class="text-lg font-bold text-gray-900">Nouveau fil</h3>
|
| 102 |
+
<button onclick="togglePostForm()" class="text-gray-400 hover:text-gray-600 p-2 rounded-full hover:bg-gray-100">
|
| 103 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 104 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 105 |
+
</svg>
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div class="p-6 overflow-y-auto">
|
| 110 |
+
<form method="POST" enctype="multipart/form-data" class="space-y-5">
|
| 111 |
+
{{ form.hidden_tag() }}
|
| 112 |
+
<div class="hidden">
|
| 113 |
+
{{ form.honeypot() }}
|
| 114 |
+
</div>
|
| 115 |
+
<div>
|
| 116 |
+
<label class="block text-gray-700 text-sm font-semibold mb-2" for="nickname">Pseudo</label>
|
| 117 |
+
{{ form.nickname(class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent", placeholder="Pseudo (Optionnel)") }}
|
| 118 |
+
</div>
|
| 119 |
+
<div>
|
| 120 |
+
<label class="block text-gray-700 text-sm font-semibold mb-2" for="content">Message</label>
|
| 121 |
+
{{ form.content(class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent h-40 resize-none text-base", placeholder="Écrivez quelque chose...") }}
|
| 122 |
+
{% for error in form.content.errors %}
|
| 123 |
+
<p class="text-red-500 text-xs mt-1">{{ error }}</p>
|
| 124 |
+
{% endfor %}
|
| 125 |
+
</div>
|
| 126 |
+
<div>
|
| 127 |
+
<label class="block text-gray-700 text-sm font-semibold mb-2" for="files">Images ou Vidéos</label>
|
| 128 |
+
<div class="flex items-center justify-center w-full">
|
| 129 |
+
<label for="files" class="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-200 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100">
|
| 130 |
+
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
| 131 |
+
<svg class="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
|
| 132 |
+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
|
| 133 |
+
</svg>
|
| 134 |
+
<p class="text-sm text-gray-500"><span class="font-semibold">Cliquez pour uploader</span></p>
|
| 135 |
+
</div>
|
| 136 |
+
{{ form.files(class="hidden", multiple=True, accept="image/*,video/*", onchange="previewFiles(this, 'preview-board')") }}
|
| 137 |
+
</label>
|
| 138 |
+
</div>
|
| 139 |
+
<div id="preview-board" class="flex flex-wrap gap-2 mt-2"></div>
|
| 140 |
+
{% for error in form.files.errors %}
|
| 141 |
+
<p class="text-red-500 text-xs mt-1">{{ error }}</p>
|
| 142 |
+
{% endfor %}
|
| 143 |
+
</div>
|
| 144 |
+
<div class="pt-2">
|
| 145 |
+
{{ form.submit(class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg cursor-pointer shadow-md active:scale-95 transition-transform") }}
|
| 146 |
+
</div>
|
| 147 |
+
</form>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
{% endblock %}
|
templates/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<div class="text-center py-8">
|
| 5 |
+
<h1 class="text-4xl font-extrabold mb-2 text-gray-900 tracking-tight">GhostBoard</h1>
|
| 6 |
+
<p class="text-lg text-gray-500">Anonyme & Sans Friction.</p>
|
| 7 |
+
</div>
|
| 8 |
+
|
| 9 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 max-w-5xl mx-auto px-4">
|
| 10 |
+
{% for board in boards %}
|
| 11 |
+
<a href="{{ url_for('board', slug=board.slug) }}" class="block p-5 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-blue-200 transition-all duration-200 active:scale-95">
|
| 12 |
+
<h2 class="text-xl font-bold text-blue-600 mb-1">/{{ board.slug }}/</h2>
|
| 13 |
+
<h3 class="text-lg text-gray-900 font-semibold">{{ board.name }}</h3>
|
| 14 |
+
<p class="text-gray-500 text-sm mt-1">{{ board.description }}</p>
|
| 15 |
+
</a>
|
| 16 |
+
{% endfor %}
|
| 17 |
+
</div>
|
| 18 |
+
{% endblock %}
|
templates/thread.html
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}/{{ board.slug }}/ - Thread #{{ thread.id }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<!-- Add padding bottom to account for fixed bottom bar -->
|
| 7 |
+
<div class="max-w-4xl mx-auto pb-40 px-3 sm:px-6">
|
| 8 |
+
<div class="mb-6 pt-6">
|
| 9 |
+
<a href="{{ url_for('board', slug=board.slug) }}" class="inline-flex items-center text-slate-500 hover:text-blue-600 transition-colors font-medium text-sm">
|
| 10 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" viewBox="0 0 20 20" fill="currentColor">
|
| 11 |
+
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
|
| 12 |
+
</svg>
|
| 13 |
+
Retour à /{{ board.slug }}/
|
| 14 |
+
</a>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- Main Thread Post -->
|
| 18 |
+
<!-- "Think Harder": Make OP look distinct, like a primary content block, not just another box -->
|
| 19 |
+
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5 sm:p-6 mb-10 relative overflow-hidden" id="post-{{ thread.id }}">
|
| 20 |
+
<!-- Decorative subtle background gradient for OP -->
|
| 21 |
+
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500"></div>
|
| 22 |
+
|
| 23 |
+
<div class="flex flex-col gap-6">
|
| 24 |
+
<!-- Header: Author & Meta -->
|
| 25 |
+
<div class="flex items-center justify-between border-b border-slate-50 pb-4">
|
| 26 |
+
<div class="flex items-center gap-3">
|
| 27 |
+
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-lg shadow-md select-none">
|
| 28 |
+
{{ thread.nickname[0] | upper }}
|
| 29 |
+
</div>
|
| 30 |
+
<div class="flex flex-col">
|
| 31 |
+
<span class="font-bold text-slate-900 text-sm">{{ thread.nickname }}</span>
|
| 32 |
+
<div class="flex items-center text-xs text-slate-400 gap-2">
|
| 33 |
+
<span>{{ thread.created_at.strftime('%d %b %Y à %H:%M') }}</span>
|
| 34 |
+
<span class="w-1 h-1 rounded-full bg-slate-300"></span>
|
| 35 |
+
<a href="#post-{{ thread.id }}" class="hover:text-blue-500">N° {{ thread.id }}</a>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="flex gap-2">
|
| 41 |
+
<button class="text-slate-400 hover:text-blue-600 p-2 hover:bg-slate-50 rounded-full transition-colors" onclick="scrollToReply('{{ thread.id }}')" title="Répondre">
|
| 42 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 43 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
| 44 |
+
</svg>
|
| 45 |
+
</button>
|
| 46 |
+
<button class="text-slate-400 hover:text-green-600 p-2 hover:bg-green-50 rounded-full transition-colors" onclick="sharePost('{{ thread.id }}', '{{ url_for('thread', thread_id=thread.id, _external=True) }}')" title="Partager">
|
| 47 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 48 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
| 49 |
+
</svg>
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<!-- Content Body -->
|
| 55 |
+
<div class="space-y-6">
|
| 56 |
+
<!-- Text -->
|
| 57 |
+
<div class="prose prose-slate max-w-none text-slate-800 text-lg leading-relaxed break-words">
|
| 58 |
+
{{ thread.content|format_post }}
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Media Grid -->
|
| 62 |
+
{% if thread.files %}
|
| 63 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 64 |
+
{% for file in thread.files %}
|
| 65 |
+
<div class="relative group rounded-xl overflow-hidden bg-slate-100 border border-slate-200 shadow-sm">
|
| 66 |
+
{% if file.filename.endswith(('.mp4', '.webm')) %}
|
| 67 |
+
<video controls class="w-full h-full object-cover max-h-[500px]">
|
| 68 |
+
<source src="{{ url_for('static', filename='uploads/' + file.filename) }}" type="video/{{ file.filename.split('.')[-1] }}">
|
| 69 |
+
</video>
|
| 70 |
+
{% else %}
|
| 71 |
+
<img
|
| 72 |
+
src="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 73 |
+
data-thumb="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 74 |
+
data-full="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 75 |
+
alt="Post image"
|
| 76 |
+
class="post-image w-full h-auto object-contain max-h-[500px] cursor-pointer"
|
| 77 |
+
>
|
| 78 |
+
{% endif %}
|
| 79 |
+
<a href="{{ url_for('static', filename='uploads/' + file.filename) }}" target="_blank" class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity backdrop-blur-sm">
|
| 80 |
+
Full Size
|
| 81 |
+
</a>
|
| 82 |
+
</div>
|
| 83 |
+
{% endfor %}
|
| 84 |
+
</div>
|
| 85 |
+
{% endif %}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<!-- Replies List (Chat/Social Style) -->
|
| 91 |
+
<!-- "Think Harder": Use spacing and grouping to make it feel like a flowing conversation -->
|
| 92 |
+
<div class="space-y-6" id="comments-list">
|
| 93 |
+
{% for reply in replies %}
|
| 94 |
+
<div class="group flex gap-3 sm:gap-4 animate-fade-in" id="post-{{ reply.id }}">
|
| 95 |
+
<!-- Avatar -->
|
| 96 |
+
<div class="flex-shrink-0 pt-1">
|
| 97 |
+
<div class="w-9 h-9 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-bold text-xs shadow-sm select-none border border-white">
|
| 98 |
+
{{ reply.nickname[0] | upper }}
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div class="flex-grow min-w-0 max-w-full">
|
| 103 |
+
<div class="flex flex-col items-start">
|
| 104 |
+
|
| 105 |
+
<!-- Bubble Container -->
|
| 106 |
+
<div class="relative max-w-full sm:max-w-[85%]">
|
| 107 |
+
<!-- Name (Facebook style: outside top left, or inside?)
|
| 108 |
+
Let's do: Name slightly outside for clarity, bubble below.
|
| 109 |
+
-->
|
| 110 |
+
<span class="text-[13px] text-slate-900 font-bold ml-3 mb-1 block">
|
| 111 |
+
{{ reply.nickname }}
|
| 112 |
+
<!-- Optional: ID pill -->
|
| 113 |
+
<span class="text-[10px] text-slate-400 font-normal ml-1">#{{ reply.id }}</span>
|
| 114 |
+
</span>
|
| 115 |
+
|
| 116 |
+
<div class="bg-slate-100 rounded-2xl rounded-tl-md px-4 py-3 text-slate-800 break-words shadow-sm border border-slate-200/50">
|
| 117 |
+
<!-- Reply files -->
|
| 118 |
+
{% if reply.files %}
|
| 119 |
+
<div class="flex flex-wrap gap-2 mb-3">
|
| 120 |
+
{% for file in reply.files %}
|
| 121 |
+
<div class="rounded-lg overflow-hidden border border-slate-200">
|
| 122 |
+
{% if file.filename.endswith(('.mp4', '.webm')) %}
|
| 123 |
+
<video controls class="max-w-full max-h-64">
|
| 124 |
+
<source src="{{ url_for('static', filename='uploads/' + file.filename) }}" type="video/{{ file.filename.split('.')[-1] }}">
|
| 125 |
+
</video>
|
| 126 |
+
{% else %}
|
| 127 |
+
<img
|
| 128 |
+
src="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 129 |
+
data-thumb="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 130 |
+
data-full="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
| 131 |
+
alt="Post image"
|
| 132 |
+
class="post-image max-w-full max-h-64 object-cover cursor-pointer hover:opacity-95 transition-opacity"
|
| 133 |
+
>
|
| 134 |
+
{% endif %}
|
| 135 |
+
</div>
|
| 136 |
+
{% endfor %}
|
| 137 |
+
</div>
|
| 138 |
+
{% endif %}
|
| 139 |
+
|
| 140 |
+
<div class="prose prose-sm max-w-none text-[15px] leading-snug text-slate-800">
|
| 141 |
+
{{ reply.content|format_post }}
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<!-- Metadata / Actions Line -->
|
| 147 |
+
<div class="flex items-center gap-4 mt-1.5 ml-3 text-xs text-slate-500 font-medium">
|
| 148 |
+
<span>{{ reply.created_at.strftime('%H:%M') }}</span> <!-- Simplify time -->
|
| 149 |
+
|
| 150 |
+
<button class="text-slate-600 hover:text-blue-600 font-bold cursor-pointer transition-colors" onclick="scrollToReply('{{ reply.id }}')">
|
| 151 |
+
Répondre
|
| 152 |
+
</button>
|
| 153 |
+
|
| 154 |
+
<button class="hover:text-green-600 flex items-center gap-1 transition-colors" onclick="sharePost('{{ reply.id }}', '{{ url_for('thread', thread_id=thread.id, _external=True) }}#post-{{ reply.id }}')">
|
| 155 |
+
Partager
|
| 156 |
+
</button>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
{% endfor %}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<!-- Sticky Bottom Input Bar -->
|
| 166 |
+
<!-- "Think Harder": Floating Pill Design -->
|
| 167 |
+
<div class="fixed bottom-0 left-0 right-0 z-50 pointer-events-none pb-safe">
|
| 168 |
+
<div class="bg-gradient-to-t from-white via-white/95 to-transparent pt-10 pb-2 px-2 sm:px-4 pointer-events-auto">
|
| 169 |
+
<div class="max-w-4xl mx-auto w-full">
|
| 170 |
+
|
| 171 |
+
<!-- Reply Context (Floating above bar) -->
|
| 172 |
+
<div id="reply-context" class="hidden mb-2 mx-2 bg-white/90 backdrop-blur border border-blue-100 shadow-lg rounded-xl px-4 py-2 flex justify-between items-center text-xs text-blue-600 animate-slide-up">
|
| 173 |
+
<span class="flex items-center gap-2">
|
| 174 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 175 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
| 176 |
+
</svg>
|
| 177 |
+
Réponse au post #<span id="reply-to-id" class="font-bold"></span>
|
| 178 |
+
</span>
|
| 179 |
+
<button onclick="clearReplyContext()" class="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100">
|
| 180 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 181 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 182 |
+
</svg>
|
| 183 |
+
</button>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<!-- File Previews (Floating above bar) -->
|
| 187 |
+
<div id="preview-reply" class="hidden mb-2 mx-2 flex gap-2 overflow-x-auto p-2 bg-white/90 backdrop-blur border border-slate-100 rounded-xl shadow-lg scrollbar-hide">
|
| 188 |
+
<!-- JS populates this -->
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<form method="POST" enctype="multipart/form-data" class="bg-white shadow-[0_0_20px_rgba(0,0,0,0.1)] rounded-[2rem] p-1.5 flex items-end gap-2 border border-slate-100" id="sticky-form">
|
| 192 |
+
{{ form.hidden_tag() }}
|
| 193 |
+
<div class="hidden">
|
| 194 |
+
{{ form.honeypot() }}
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- Nickname Toggle Button -->
|
| 198 |
+
<div class="relative shrink-0">
|
| 199 |
+
<button type="button" onclick="document.getElementById('nickname-input').classList.toggle('hidden')" class="w-10 h-10 flex items-center justify-center text-slate-400 hover:text-blue-500 rounded-full hover:bg-slate-50 transition-colors" title="Identité">
|
| 200 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 201 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
| 202 |
+
</svg>
|
| 203 |
+
</button>
|
| 204 |
+
<!-- Popup Nickname Input -->
|
| 205 |
+
<div id="nickname-input" class="hidden absolute bottom-full left-0 mb-4 ml-1 w-56 bg-white shadow-xl border border-slate-100 rounded-2xl p-3 animate-fade-in-up">
|
| 206 |
+
<label class="block text-xs font-bold text-slate-500 mb-1">Votre Pseudo</label>
|
| 207 |
+
{{ form.nickname(class="w-full text-sm bg-slate-50 border-transparent rounded-lg focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all px-3 py-2", placeholder="Anonyme") }}
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<!-- File Upload Button -->
|
| 212 |
+
<label for="files" class="w-10 h-10 flex items-center justify-center text-slate-400 hover:text-blue-500 rounded-full hover:bg-slate-50 cursor-pointer transition-colors shrink-0" title="Ajouter médias">
|
| 213 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 214 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
| 215 |
+
</svg>
|
| 216 |
+
{{ form.files(class="hidden", multiple=True, accept="image/*,video/*", onchange="previewFiles(this, 'preview-reply')") }}
|
| 217 |
+
</label>
|
| 218 |
+
|
| 219 |
+
<!-- Text Input -->
|
| 220 |
+
<div class="flex-grow py-2">
|
| 221 |
+
{{ form.content(class="w-full bg-transparent border-none focus:ring-0 p-0 text-slate-800 placeholder-slate-400 resize-none max-h-32 min-h-[1.5rem] leading-relaxed", placeholder="Ajouter un commentaire...", rows="1", oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'") }}
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<!-- Submit Button -->
|
| 225 |
+
<button type="submit" class="w-10 h-10 flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all shadow-md hover:shadow-lg active:scale-95 shrink-0">
|
| 226 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-0.5" viewBox="0 0 20 20" fill="currentColor">
|
| 227 |
+
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
|
| 228 |
+
</svg>
|
| 229 |
+
</button>
|
| 230 |
+
</form>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<style>
|
| 236 |
+
/* Custom animations for smooth feel */
|
| 237 |
+
@keyframes fadeIn {
|
| 238 |
+
from { opacity: 0; }
|
| 239 |
+
to { opacity: 1; }
|
| 240 |
+
}
|
| 241 |
+
.animate-fade-in {
|
| 242 |
+
animation: fadeIn 0.3s ease-out forwards;
|
| 243 |
+
}
|
| 244 |
+
@keyframes fadeInUp {
|
| 245 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 246 |
+
to { opacity: 1; transform: translateY(0); }
|
| 247 |
+
}
|
| 248 |
+
.animate-fade-in-up {
|
| 249 |
+
animation: fadeInUp 0.2s ease-out forwards;
|
| 250 |
+
}
|
| 251 |
+
@keyframes slideUp {
|
| 252 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 253 |
+
to { opacity: 1; transform: translateY(0); }
|
| 254 |
+
}
|
| 255 |
+
.animate-slide-up {
|
| 256 |
+
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 257 |
+
}
|
| 258 |
+
.pb-safe {
|
| 259 |
+
padding-bottom: env(safe-area-inset-bottom);
|
| 260 |
+
}
|
| 261 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 262 |
+
display: none;
|
| 263 |
+
}
|
| 264 |
+
.scrollbar-hide {
|
| 265 |
+
-ms-overflow-style: none;
|
| 266 |
+
scrollbar-width: none;
|
| 267 |
+
}
|
| 268 |
+
</style>
|
| 269 |
+
|
| 270 |
+
<script>
|
| 271 |
+
// Custom file preview for the sticky bar
|
| 272 |
+
function previewFiles(input, previewId) {
|
| 273 |
+
const preview = document.getElementById(previewId);
|
| 274 |
+
const container = preview;
|
| 275 |
+
|
| 276 |
+
container.innerHTML = '';
|
| 277 |
+
container.classList.remove('hidden');
|
| 278 |
+
|
| 279 |
+
if (input.files) {
|
| 280 |
+
if (input.files.length === 0) {
|
| 281 |
+
container.classList.add('hidden');
|
| 282 |
+
return;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
Array.from(input.files).forEach(file => {
|
| 286 |
+
const reader = new FileReader();
|
| 287 |
+
reader.onload = function(e) {
|
| 288 |
+
const div = document.createElement('div');
|
| 289 |
+
div.className = 'relative flex-shrink-0 w-16 h-16 group';
|
| 290 |
+
|
| 291 |
+
let content;
|
| 292 |
+
if (file.type.startsWith('video/')) {
|
| 293 |
+
content = `<video src="${e.target.result}" class="w-full h-full object-cover rounded-lg bg-black"></video>`;
|
| 294 |
+
} else {
|
| 295 |
+
content = `<img src="${e.target.result}" class="w-full h-full object-cover rounded-lg border border-slate-200 shadow-sm">`;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Add remove button overlay
|
| 299 |
+
const removeBtn = document.createElement('div');
|
| 300 |
+
removeBtn.innerHTML = `
|
| 301 |
+
<div class="absolute inset-0 bg-black/40 flex items-center justify-center rounded-lg opacity-0 group-hover:opacity-100 transition-opacity">
|
| 302 |
+
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
| 303 |
+
</div>
|
| 304 |
+
`;
|
| 305 |
+
|
| 306 |
+
div.innerHTML = content;
|
| 307 |
+
div.appendChild(removeBtn); // Append the overlay
|
| 308 |
+
|
| 309 |
+
// Note: Actual file removal from input requires DataTransfer API which is complex for this simple UI,
|
| 310 |
+
// so we just visually show it. For now let's keep it simple.
|
| 311 |
+
container.appendChild(div);
|
| 312 |
+
}
|
| 313 |
+
reader.readAsDataURL(file);
|
| 314 |
+
});
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function scrollToReply(id) {
|
| 319 |
+
const textarea = document.querySelector('textarea[name="content"]');
|
| 320 |
+
textarea.focus();
|
| 321 |
+
textarea.value += '>>' + id + '\n';
|
| 322 |
+
// Trigger auto resize
|
| 323 |
+
textarea.style.height = 'auto';
|
| 324 |
+
textarea.style.height = textarea.scrollHeight + 'px';
|
| 325 |
+
|
| 326 |
+
// Update UI for "Replying to..."
|
| 327 |
+
const context = document.getElementById('reply-context');
|
| 328 |
+
const contextId = document.getElementById('reply-to-id');
|
| 329 |
+
context.classList.remove('hidden');
|
| 330 |
+
contextId.textContent = id;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function clearReplyContext() {
|
| 334 |
+
document.getElementById('reply-context').classList.add('hidden');
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Auto-resize textarea on page load if content exists (e.g. after error)
|
| 338 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 339 |
+
const textarea = document.querySelector('textarea[name="content"]');
|
| 340 |
+
if (textarea.value) {
|
| 341 |
+
textarea.style.height = textarea.scrollHeight + 'px';
|
| 342 |
+
}
|
| 343 |
+
});
|
| 344 |
+
</script>
|
| 345 |
+
|
| 346 |
+
{% endblock %}
|
verification/before_changes.png
ADDED
|
verification/capture_before.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from playwright.sync_api import sync_playwright
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
def run():
|
| 5 |
+
with sync_playwright() as p:
|
| 6 |
+
browser = p.chromium.launch(headless=True)
|
| 7 |
+
context = browser.new_context(viewport={'width': 414, 'height': 896}) # iPhone 11 Pro dimensions
|
| 8 |
+
page = context.new_page()
|
| 9 |
+
|
| 10 |
+
# Go to a board
|
| 11 |
+
page.goto('http://127.0.0.1:5000/g/')
|
| 12 |
+
|
| 13 |
+
# Click on the first thread to go to thread view
|
| 14 |
+
print("Navigating to thread...")
|
| 15 |
+
# Try finding the link by href or text
|
| 16 |
+
# The 'Voir le fil' button
|
| 17 |
+
link = page.locator("a", has_text="Voir le fil").first
|
| 18 |
+
if link.count() > 0:
|
| 19 |
+
link.click()
|
| 20 |
+
page.wait_for_load_state('networkidle')
|
| 21 |
+
|
| 22 |
+
# Add a reply if not present (optional, but good for visualization)
|
| 23 |
+
if page.locator("#comments-list > div").count() == 0:
|
| 24 |
+
print("Adding a reply...")
|
| 25 |
+
page.fill('textarea[name="content"]', "This is a test reply to check the UI.")
|
| 26 |
+
page.click('button[type="submit"]')
|
| 27 |
+
page.wait_for_load_state('networkidle')
|
| 28 |
+
|
| 29 |
+
# Take a screenshot of the thread view
|
| 30 |
+
print("Taking screenshot...")
|
| 31 |
+
page.screenshot(path='verification/before_changes.png', full_page=True)
|
| 32 |
+
else:
|
| 33 |
+
print("Could not find a thread link. Dumping page content.")
|
| 34 |
+
print(page.content())
|
| 35 |
+
|
| 36 |
+
browser.close()
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
run()
|
verification/thread_view.png
ADDED
|
verification/verify_comments.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from playwright.sync_api import sync_playwright
|
| 2 |
+
|
| 3 |
+
def verify_comments_layout():
|
| 4 |
+
with sync_playwright() as p:
|
| 5 |
+
browser = p.chromium.launch(headless=True)
|
| 6 |
+
# Use a mobile viewport to check the sticky bar
|
| 7 |
+
context = browser.new_context(viewport={'width': 375, 'height': 812})
|
| 8 |
+
page = context.new_page()
|
| 9 |
+
|
| 10 |
+
# Go to the home page first to ensure DB is init
|
| 11 |
+
page.goto("http://127.0.0.1:5000/")
|
| 12 |
+
|
| 13 |
+
# Navigate to a board
|
| 14 |
+
page.click("text=Général")
|
| 15 |
+
|
| 16 |
+
# Create a thread so we have something to comment on
|
| 17 |
+
# Open the modal first
|
| 18 |
+
page.click("#fab-button")
|
| 19 |
+
|
| 20 |
+
page.fill("textarea[name='content']", "Hello World Thread")
|
| 21 |
+
|
| 22 |
+
# The submit button text comes from the WTF SubmitField('Envoyer')
|
| 23 |
+
page.click("input[value='Envoyer']")
|
| 24 |
+
|
| 25 |
+
# Now click the "Répondre" button on the thread in the board view to go to thread detail
|
| 26 |
+
# Or just click the "No X" link
|
| 27 |
+
# The page reloads, so we should see the new thread
|
| 28 |
+
page.click(".text-blue-500", strict=False) # Click the first link like "N° 1"
|
| 29 |
+
|
| 30 |
+
# Now we are in the thread view.
|
| 31 |
+
# 1. Check if the sticky bottom bar is visible
|
| 32 |
+
page.wait_for_selector("#sticky-form")
|
| 33 |
+
|
| 34 |
+
# 2. Add a comment
|
| 35 |
+
page.fill("#sticky-form textarea[name='content']", "First reply!")
|
| 36 |
+
# The thread view uses a custom icon button for submit, not the WTForms 'Envoyer' input
|
| 37 |
+
page.click("#sticky-form button[type='submit']")
|
| 38 |
+
|
| 39 |
+
# 3. Add another comment
|
| 40 |
+
page.fill("#sticky-form textarea[name='content']", "Second reply!")
|
| 41 |
+
page.click("#sticky-form button[type='submit']")
|
| 42 |
+
|
| 43 |
+
# 4. Check if comments are displayed in bubbles
|
| 44 |
+
page.wait_for_selector("#comments-list")
|
| 45 |
+
|
| 46 |
+
# Take a screenshot of the thread with comments and the sticky bar
|
| 47 |
+
page.screenshot(path="verification/thread_view.png", full_page=False)
|
| 48 |
+
|
| 49 |
+
browser.close()
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
verify_comments_layout()
|