Docfile commited on
Commit
cb18dab
·
verified ·
1 Parent(s): ed8bb45

Upload 21 files

Browse files
__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})">&gt;&gt;{post_id}</a>'
125
+
126
+ content = re.sub(r'&gt;&gt;(\d+)', replace_quote, content)
127
+
128
+ # Greentext (lines starting with >)
129
+ content = re.sub(r'^(&gt;.*)$', 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 &copy; 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()