| | {% extends 'base.html' %} |
| |
|
| | {% block title %}/{{ board.slug }}/ - Thread #{{ thread.id }}{% endblock %} |
| |
|
| | {% block content %} |
| | |
| | <div class="max-w-4xl mx-auto pb-40 px-3 sm:px-6"> |
| | <div class="mb-6 pt-6"> |
| | <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"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" viewBox="0 0 20 20" fill="currentColor"> |
| | <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" /> |
| | </svg> |
| | Retour à /{{ board.slug }}/ |
| | </a> |
| | </div> |
| |
|
| | |
| | |
| | <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 }}"> |
| | |
| | <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> |
| |
|
| | <div class="flex flex-col gap-6"> |
| | |
| | <div class="flex items-center justify-between border-b border-slate-50 pb-4"> |
| | <div class="flex items-center gap-3"> |
| | <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"> |
| | {{ thread.nickname[0] | upper }} |
| | </div> |
| | <div class="flex flex-col"> |
| | <span class="font-bold text-slate-900 text-sm">{{ thread.nickname }}</span> |
| | <div class="flex items-center text-xs text-slate-400 gap-2"> |
| | <span>{{ thread.created_at.strftime('%d %b %Y à %H:%M') }}</span> |
| | <span class="w-1 h-1 rounded-full bg-slate-300"></span> |
| | <a href="#post-{{ thread.id }}" class="hover:text-blue-500">N° {{ thread.id }}</a> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <div class="flex gap-2"> |
| | <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"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /> |
| | </svg> |
| | </button> |
| | <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"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <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" /> |
| | </svg> |
| | </button> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="space-y-6"> |
| | |
| | <div class="prose prose-slate max-w-none text-slate-800 text-lg leading-relaxed break-words"> |
| | {{ thread.content|format_post(post_context) }} |
| | </div> |
| |
|
| | |
| | {% if thread.files %} |
| | <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> |
| | {% for file in thread.files %} |
| | <div class="relative group rounded-xl overflow-hidden bg-slate-100 border border-slate-200 shadow-sm"> |
| | {% if file.filename.endswith(('.mp4', '.webm')) %} |
| | <video controls class="w-full h-full object-cover max-h-[500px]"> |
| | <source src="{{ url_for('static', filename='uploads/' + file.filename) }}" type="video/{{ file.filename.split('.')[-1] }}"> |
| | </video> |
| | {% else %} |
| | <img |
| | src="{{ url_for('static', filename='uploads/' + file.filename) }}" |
| | data-thumb="{{ url_for('static', filename='uploads/' + file.filename) }}" |
| | data-full="{{ url_for('static', filename='uploads/' + file.filename) }}" |
| | alt="Post image" |
| | class="post-image w-full h-auto object-contain max-h-[500px] cursor-pointer" |
| | > |
| | {% endif %} |
| | <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"> |
| | Full Size |
| | </a> |
| | </div> |
| | {% endfor %} |
| | </div> |
| | {% endif %} |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | |
| | <div class="space-y-6" id="comments-list"> |
| | {% for reply in replies %} |
| | <div class="group flex gap-3 sm:gap-4 animate-fade-in" id="post-{{ reply.id }}"> |
| | |
| | <div class="flex-shrink-0 pt-1"> |
| | <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"> |
| | {{ reply.nickname[0] | upper }} |
| | </div> |
| | </div> |
| |
|
| | <div class="flex-grow min-w-0 max-w-full"> |
| | <div class="flex flex-col items-start"> |
| | |
| | |
| | <div class="relative max-w-full sm:max-w-[85%]"> |
| | |
| | |
| | |
| | <span class="text-[13px] text-slate-900 font-bold ml-3 mb-1 block"> |
| | {{ reply.nickname }} |
| | |
| | <span class="text-[10px] text-slate-400 font-normal ml-1">#{{ reply.id }}</span> |
| | </span> |
| |
|
| | <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"> |
| | |
| | {% if reply.files %} |
| | <div class="flex flex-wrap gap-2 mb-3"> |
| | {% for file in reply.files %} |
| | <div class="rounded-lg overflow-hidden border border-slate-200"> |
| | {% if file.filename.endswith(('.mp4', '.webm')) %} |
| | <video controls class="max-w-full max-h-64"> |
| | <source src="{{ url_for('static', filename='uploads/' + file.filename) }}" type="video/{{ file.filename.split('.')[-1] }}"> |
| | </video> |
| | {% else %} |
| | <img |
| | src="{{ url_for('static', filename='uploads/' + file.filename) }}" |
| | data-thumb="{{ url_for('static', filename='uploads/' + file.filename) }}" |
| | data-full="{{ url_for('static', filename='uploads/' + file.filename) }}" |
| | alt="Post image" |
| | class="post-image max-w-full max-h-64 object-cover cursor-pointer hover:opacity-95 transition-opacity" |
| | > |
| | {% endif %} |
| | </div> |
| | {% endfor %} |
| | </div> |
| | {% endif %} |
| |
|
| | <div class="prose prose-sm max-w-none text-[15px] leading-snug text-slate-800"> |
| | {{ reply.content|format_post(post_context) }} |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div class="flex items-center gap-4 mt-1.5 ml-3 text-xs text-slate-500 font-medium"> |
| | <span>{{ reply.created_at.strftime('%H:%M') }}</span> |
| |
|
| | <button class="text-slate-600 hover:text-blue-600 font-bold cursor-pointer transition-colors" onclick="scrollToReply('{{ reply.id }}')"> |
| | Répondre |
| | </button> |
| |
|
| | <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 }}')"> |
| | Partager |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | {% endfor %} |
| | </div> |
| | </div> |
| |
|
| | |
| | |
| | <div class="fixed bottom-0 left-0 right-0 z-50 pointer-events-none pb-safe"> |
| | <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"> |
| | <div class="max-w-4xl mx-auto w-full"> |
| |
|
| | |
| | <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"> |
| | <span class="flex items-center gap-2"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /> |
| | </svg> |
| | Réponse au post #<span id="reply-to-id" class="font-bold"></span> |
| | </span> |
| | <button onclick="clearReplyContext()" class="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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> |
| | </button> |
| | </div> |
| |
|
| | |
| | <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"> |
| | |
| | </div> |
| |
|
| | <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"> |
| | {{ form.hidden_tag() }} |
| | <div class="hidden"> |
| | {{ form.honeypot() }} |
| | </div> |
| |
|
| | |
| | <div class="relative shrink-0"> |
| | <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é"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <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" /> |
| | </svg> |
| | </button> |
| | |
| | <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"> |
| | <label class="block text-xs font-bold text-slate-500 mb-1">Votre Pseudo</label> |
| | {{ 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") }} |
| | </div> |
| | </div> |
| |
|
| | |
| | <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"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <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" /> |
| | </svg> |
| | {{ form.files(class="hidden", multiple=True, accept="image/*,video/*", onchange="previewFiles(this, 'preview-reply')") }} |
| | </label> |
| |
|
| | |
| | <div class="flex-grow py-2"> |
| | {{ 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'") }} |
| | </div> |
| |
|
| | |
| | <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"> |
| | <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-0.5" viewBox="0 0 20 20" fill="currentColor"> |
| | <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" /> |
| | </svg> |
| | </button> |
| | </form> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <style> |
| | |
| | @keyframes fadeIn { |
| | from { opacity: 0; } |
| | to { opacity: 1; } |
| | } |
| | .animate-fade-in { |
| | animation: fadeIn 0.3s ease-out forwards; |
| | } |
| | @keyframes fadeInUp { |
| | from { opacity: 0; transform: translateY(10px); } |
| | to { opacity: 1; transform: translateY(0); } |
| | } |
| | .animate-fade-in-up { |
| | animation: fadeInUp 0.2s ease-out forwards; |
| | } |
| | @keyframes slideUp { |
| | from { opacity: 0; transform: translateY(10px); } |
| | to { opacity: 1; transform: translateY(0); } |
| | } |
| | .animate-slide-up { |
| | animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; |
| | } |
| | .pb-safe { |
| | padding-bottom: env(safe-area-inset-bottom); |
| | } |
| | .scrollbar-hide::-webkit-scrollbar { |
| | display: none; |
| | } |
| | .scrollbar-hide { |
| | -ms-overflow-style: none; |
| | scrollbar-width: none; |
| | } |
| | </style> |
| |
|
| | <script> |
| | |
| | function previewFiles(input, previewId) { |
| | const preview = document.getElementById(previewId); |
| | const container = preview; |
| | |
| | container.innerHTML = ''; |
| | container.classList.remove('hidden'); |
| | |
| | if (input.files) { |
| | if (input.files.length === 0) { |
| | container.classList.add('hidden'); |
| | return; |
| | } |
| | |
| | Array.from(input.files).forEach(file => { |
| | const reader = new FileReader(); |
| | reader.onload = function(e) { |
| | const div = document.createElement('div'); |
| | div.className = 'relative flex-shrink-0 w-16 h-16 group'; |
| | |
| | let content; |
| | if (file.type.startsWith('video/')) { |
| | content = `<video src="${e.target.result}" class="w-full h-full object-cover rounded-lg bg-black"></video>`; |
| | } else { |
| | content = `<img src="${e.target.result}" class="w-full h-full object-cover rounded-lg border border-slate-200 shadow-sm">`; |
| | } |
| | |
| | |
| | const removeBtn = document.createElement('div'); |
| | removeBtn.innerHTML = ` |
| | <div class="absolute inset-0 bg-black/40 flex items-center justify-center rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"> |
| | <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> |
| | </div> |
| | `; |
| | |
| | div.innerHTML = content; |
| | div.appendChild(removeBtn); |
| | |
| | |
| | |
| | container.appendChild(div); |
| | } |
| | reader.readAsDataURL(file); |
| | }); |
| | } |
| | } |
| | |
| | function scrollToReply(id) { |
| | const textarea = document.querySelector('textarea[name="content"]'); |
| | textarea.focus(); |
| | textarea.value += '>>' + id + '\n'; |
| | |
| | textarea.style.height = 'auto'; |
| | textarea.style.height = textarea.scrollHeight + 'px'; |
| | |
| | |
| | const context = document.getElementById('reply-context'); |
| | const contextId = document.getElementById('reply-to-id'); |
| | context.classList.remove('hidden'); |
| | contextId.textContent = id; |
| | } |
| | |
| | function clearReplyContext() { |
| | document.getElementById('reply-context').classList.add('hidden'); |
| | } |
| | |
| | |
| | document.addEventListener('DOMContentLoaded', function() { |
| | const textarea = document.querySelector('textarea[name="content"]'); |
| | if (textarea.value) { |
| | textarea.style.height = textarea.scrollHeight + 'px'; |
| | } |
| | }); |
| | </script> |
| |
|
| | {% endblock %} |
| |
|