git / moonbite-network / templates/discussion_thread.html · raw
{% extends "base.html" %} {% import "partials/forum_macros.html" as fm with context %} {% block body_class %}{% endblock %} {% block title %}{% if thread %}{{ thread.title }} — MoonBite Discussions{% else %}Thread not found — MoonBite{% endif %}{% endblock %} {% block meta_description %}{% if thread %}{{ thread.excerpt }}{% else %}A discussion thread on the MoonBite community forum.{% endif %}{% endblock %} {% block head_extra %} {% include "partials/forum_style.html" %} <style nonce="{{ csp_nonce }}"> /* Post page (after Reddit): post card, comment box, nested comment tree. */ .fx-main { padding: 32px 0 96px; } .fx-crumbbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 16px; } .fx-back { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 999px; background: var(--ds-slate); border: 1px solid var(--ds-line); color: var(--ds-bone); } .fx-back:hover { border-color: var(--ds-line-2); } /* the post */ .fx-postcard { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 12px; padding: 20px 24px 16px 8px; border: 1px solid var(--ds-line); border-radius: 10px; background: var(--ds-slate); } .fx-postcard .meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; font-size: 13px; color: var(--ds-ash); } .fx-postcard .meta b { color: var(--ds-bone); font-weight: 600; } .fx-postcard .meta .dot::before { content: "·"; } .fx-postcard .fx-cat { color: var(--ds-bone); } .fx-h1 { font-family: var(--ds-sans); font-weight: 800; font-size: clamp(24px, 3vw, 34px); line-height: 1.18; letter-spacing: -.02em; margin: 10px 0 16px; color: var(--ds-bone); text-wrap: balance; overflow-wrap: anywhere; } .fx-postcard .bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 16px 0 0 -12px; } /* comment box (top-level) */ .fx-cbox { margin-top: 24px; scroll-margin-top: 96px; } .fx-cbox details { border: 1px solid var(--ds-line-2); border-radius: 10px; background: var(--ds-ink); } .fx-cbox summary { list-style: none; cursor: text; padding: 14px 18px; color: var(--ds-ash); font-size: 15px; min-height: 52px; } .fx-cbox summary::-webkit-details-marker { display: none; } .fx-cbox details[open] summary { display: none; } .fx-cbox .fx-form { padding: 16px; } .fx-cbox textarea.ds-input { min-height: 120px; } /* sort + count */ .fx-cbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin: 32px 0 8px; scroll-margin-top: 96px; } .fx-cbar h2 { margin: 0; font-size: 16px; font-weight: 700; color: var(--ds-bone); } .fx-csort { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ds-ash); } .fx-csort a { padding: 6px 12px; border-radius: 999px; color: var(--ds-ash); min-height: 32px; display: inline-flex; align-items: center; } .fx-csort a:hover { color: var(--ds-bone); background: rgba(245, 242, 234, .05); } .fx-csort a[aria-current] { color: var(--ds-bone); background: var(--ds-raise); } body.mbs-page .ds .fx-csort a { text-decoration: none; } /* comment tree: avatar column carries the thread line; click the line to collapse */ .fx-tree { display: flex; flex-direction: column; } .fx-c { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: 0 10px; padding-top: 16px; scroll-margin-top: 96px; } .fx-c > .rail { display: flex; flex-direction: column; align-items: center; } .fx-c > .rail .fx-av { width: 32px; height: 32px; font-size: 10px; } .fx-line { flex: 1; width: 16px; margin-top: 6px; border: 0; padding: 0; background: transparent; cursor: pointer; position: relative; } .fx-line::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--ds-line-2); transform: translateX(-.5px); transition: background var(--ds-t1) var(--ds-ease), width var(--ds-t1) var(--ds-ease); } .fx-line:hover::before, .fx-line:focus-visible::before { background: var(--ds-bone); width: 2px; } .fx-c > .cm { min-width: 0; } .fx-c .ch { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-height: 32px; font-size: 13px; color: var(--ds-ash); } .fx-c .ch .who { font-weight: 700; color: var(--ds-bone); overflow-wrap: anywhere; } .fx-c .ch .op { font: 700 10px var(--ds-mono); letter-spacing: .04em; padding: 1px 5px; border-radius: 4px; background: var(--ds-gold); color: var(--ds-void); } .fx-c .ch .dot::before { content: "·"; } .fx-c .ch a.when:hover { color: var(--ds-bone); } .fx-c .ch .tog { display: none; margin-left: 4px; border: 1px solid var(--ds-line-2); background: transparent; color: var(--ds-bone); border-radius: 999px; padding: 0 8px; min-height: 24px; font: 400 12px var(--ds-mono); cursor: pointer; } .fx-c .cb { margin-top: 2px; } .fx-c .fx-body { font-size: 15px; } .fx-c .ca { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; margin: 6px 0 0 -6px; } .fx-c .slot:not(:empty) { margin-top: 8px; } .fx-c .slot .fx-form { padding: 12px; border: 1px solid var(--ds-line-2); border-radius: 10px; background: var(--ds-ink); gap: 12px; } .fx-c .slot textarea.ds-input { min-height: 96px; } .fx-c .slot .acts { display: flex; gap: 8px; justify-content: flex-end; } .fx-c:target > .cm > .cb { background: rgba(217, 164, 65, .07); box-shadow: -8px 0 0 rgba(217, 164, 65, .07), 8px 0 0 rgba(217, 164, 65, .07); border-radius: 4px; } .fx-c.collapsed > .rail .fx-line, .fx-c.collapsed > .cm > .cb, .fx-c.collapsed > .cm > .ca, .fx-c.collapsed > .cm > .slot, .fx-c.collapsed > .cm > .kids { display: none; } .fx-c.collapsed > .cm > .ch .tog { display: inline-flex; align-items: center; } .fx-c.collapsed > .cm > .ch { opacity: .75; } .fx-norep { margin-top: 16px; padding: 40px 24px; text-align: center; border: 1px dashed var(--ds-line-2); border-radius: 10px; color: var(--ds-ash); } .fx-norep b { display: block; color: var(--ds-bone); font-size: 17px; margin-bottom: 4px; } .fx-meta { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 16px; margin: 0; font-size: 14px; } .fx-meta dt { color: var(--ds-ash); } .fx-meta dd { margin: 0; color: var(--ds-bone); text-align: right; font-family: var(--ds-mono); font-size: 13px; } .fx-voices { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } .fx-voices li { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--ds-bone); overflow-wrap: anywhere; } .fx-voices .fx-av { width: 28px; height: 28px; font-size: 10px; } .fx-404 { padding: 96px 0; text-align: center; } .fx-404 p { color: var(--ds-ash); margin: 12px auto 32px; max-width: 420px; } @media (max-width: 767px) { .fx-postcard { grid-template-columns: 40px minmax(0, 1fr); gap: 8px; padding: 16px 14px 12px 4px; } .fx-c { grid-template-columns: 24px minmax(0, 1fr); gap: 0 8px; } .fx-c > .rail .fx-av { width: 24px; height: 24px; font-size: 9px; } } </style> {% endblock %} {# One comment and, recursively, its replies. #} {% macro comment(c) -%} <div class="fx-c" id="c-{{ c.id }}" data-id="{{ c.id }}" data-author="{{ c.author }}" data-depth="{{ c.depth }}"> <div class="rail"> {{ fm.av(c.author, official=c.is_official) }} <button type="button" class="fx-line" data-collapse aria-expanded="true" aria-label="Collapse comment by {{ c.author }}"></button> </div> <div class="cm"> <div class="ch"> {{ fm.author(c, "span") }} {% if c.author == thread.author %}<span class="op" title="Same name as the original poster (names aren't verified)">OP</span>{% endif %} <span class="dot"></span> <a class="when" href="#c-{{ c.id }}"><time datetime="{{ c.created_at }}" title="{{ c.created_display }}">{{ fm.ago(c.created_ago) }}</time></a> <button type="button" class="tog" data-collapse aria-expanded="false" aria-label="Expand comment by {{ c.author }}">+ {{ c.score }} pts{% if c.children %}, {{ c.children|length }} repl{{ 'y' if c.children|length == 1 else 'ies' }}{% endif %}</button> </div> {% if c.removed and not is_mod %} <div class="cb fx-body"><p class="fx-removed">This comment was removed by a moderator.</p></div> {% else %} <div class="cb fx-body">{% if c.removed %}<p class="fx-removed">Removed: only moderators can see this.</p>{% endif %}{{ render_body(c.body) }}</div> <div class="ca"> {{ fm.vote('r', c.id, c.score, mine.get(c.id, 0), 'row') }} {% if not thread.locked or is_mod %}<button type="button" class="fx-act-btn" data-reply>{{ fm.icon_comment() }} Reply</button>{% endif %} <button type="button" class="fx-act-btn" data-share="#c-{{ c.id }}">{{ fm.icon_share() }} Share</button> {{ fm.report_button('r', c.id) }} {{ fm.mod_button('r', c.id, 'restore' if c.removed else 'remove', 'Restore' if c.removed else 'Remove', link ~ '#c-' ~ c.id) }} </div> {% endif %} <div class="slot"></div> {% if c.children %}<div class="kids">{% for ch in c.children %}{{ comment(ch) }}{% endfor %}</div>{% endif %} </div> </div> {%- endmacro %} {% block content %} <div class="ds"> {% if not thread %} <section class="fx-404"> <div class="ds-wrap"> <p class="ds-eyebrow">404</p> <h1 class="ds-h2">Post not found</h1> <p>This discussion doesn't exist, or it was removed.</p> <a class="ds-btn" href="{{ url_for('discussions_page') }}">All discussions</a> </div> </section> {% else %} {% set link = url_for('discussion_thread_page', thread_id=thread.id) %} <div class="fx-main"> <div class="ds-wrap fx-layout"> <!-- ================= POST + COMMENTS ================= --> <div> <div class="fx-crumbbar"> <a class="fx-back" href="{{ url_for('discussions_page') }}" aria-label="Back to all discussions"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8H3M7 4L3 8l4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a> <ol class="ds-crumbs"> <li><a href="{{ url_for('discussions_page') }}">Discussions</a></li> <li><a href="{{ url_for('discussions_page', c=thread.category) }}">{{ thread.category_name }}</a></li> </ol> </div> {% if error %}<div class="fx-alert" role="alert">{{ error }}</div>{% endif %} <article class="fx-postcard" aria-labelledby="post-title"> {{ fm.vote('t', thread.id, thread.score, mine_thread) }} <div> <div class="meta"> <a class="fx-cat c-{{ thread.category }}" href="{{ url_for('discussions_page', c=thread.category) }}">{{ thread.category_name }}</a> <span class="dot"></span> <span>Posted by {{ fm.author(thread) }}</span> <span class="dot"></span> <time datetime="{{ thread.created_at }}" title="{{ thread.created_display }}">{{ fm.ago(thread.created_ago) }}</time> </div> {% if thread.removed %}<p class="fx-removed">Removed: only moderators can see this post.</p>{% endif %} <h1 class="fx-h1" id="post-title">{% if thread.pinned %}<span class="fx-pin">Pinned</span> {% endif %}{{ thread.title|emojify }}</h1> <div class="fx-body">{{ render_body(thread.body) }}</div> <div class="bar"> <a class="fx-act-btn pill" href="#comments">{{ fm.icon_comment() }} {{ thread.reply_count }} comment{{ '' if thread.reply_count == 1 else 's' }}</a> <button type="button" class="fx-act-btn" data-share="{{ link }}">{{ fm.icon_share() }} Share</button> {{ fm.report_button('t', thread.id) }} </div> <div class="slot"></div> {% if is_mod %} <div class="fx-modbar"> <span class="lbl">Moderator</span> {{ fm.mod_button('t', thread.id, 'unpin' if thread.pinned else 'pin', 'Unpin' if thread.pinned else 'Pin', link) }} {{ fm.mod_button('t', thread.id, 'unlock' if thread.locked else 'lock', 'Unlock' if thread.locked else 'Lock', link) }} {{ fm.mod_button('t', thread.id, 'restore' if thread.removed else 'remove', 'Restore' if thread.removed else 'Remove', link if not thread.removed else url_for('discussions_mod_page')) }} </div> {% endif %} </div> </article> <!-- top-level comment --> {% if thread.locked %}<div class="fx-lockbar" role="note"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="3" y="7" width="10" height="7" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 7V5a2.5 2.5 0 015 0v2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>A moderator locked this thread. You can read it, but new comments are closed.</div>{% endif %} {% if not thread.locked or is_mod %} <div class="fx-cbox" id="reply"> <details {% if error %}open{% endif %}> <summary>Join the conversation</summary> <form class="fx-form" method="post" action="{{ url_for('discussions_reply', thread_id=thread.id) }}"> {{ fm.honeypot() }} <div class="ds-field"> <label for="reply-author">Your name</label> <input class="ds-input" type="text" id="reply-author" name="author" maxlength="40" placeholder="anon" autocomplete="off" data-name> </div> <div class="ds-field"> <label for="reply-body">Comment</label> <textarea class="ds-input" id="reply-body" name="body" maxlength="8000" placeholder="What are your thoughts?" required data-max="8000"></textarea> </div> <div class="foot">{{ fm.format_hint() }}<span class="count" aria-live="polite"></span></div> {{ fm.as_official() }} <div class="foot"> <span class="hint">Public and stored on this server.</span> <button type="submit" class="ds-btn gold sm">Comment</button> </div> </form> </details> </div> {% endif %} <div class="fx-cbar" id="comments"> <h2>{{ thread.reply_count }} comment{{ '' if thread.reply_count == 1 else 's' }}</h2> {% if thread.reply_count > 1 %} <nav class="fx-csort" aria-label="Sort comments"> <span>Sort by</span> {% for key, label in [('best', 'Best'), ('new', 'New'), ('old', 'Old')] %} <a href="{{ url_for('discussion_thread_page', thread_id=thread.id, sort=(key if key != 'best' else None)) }}#comments" {% if sort == key %}aria-current="page"{% endif %}>{{ label }}</a> {% endfor %} </nav> {% endif %} </div> {% if thread.comments %} <div class="fx-tree">{% for c in thread.comments %}{{ comment(c) }}{% endfor %}</div> {% else %} <div class="fx-norep"><b>No comments yet</b>If you know the answer, you're the first person who can help.</div> {% endif %} <template id="fx-reply-tpl"> <form class="fx-form" method="post" action="{{ url_for('discussions_reply', thread_id=thread.id) }}"> {{ fm.honeypot() }} <input type="hidden" name="parent_id" value=""> <input class="ds-input" type="text" name="author" maxlength="40" placeholder="Your name (anon)" autocomplete="off" aria-label="Your name" data-name> <textarea class="ds-input" name="body" maxlength="8000" required data-max="8000" aria-label="Reply"></textarea> {{ fm.as_official() }} <div class="foot"> <span class="hint">{{ fm.emoji_button() }}</span> <span class="count" aria-live="polite"></span> <span class="acts"> <button type="button" class="ds-btn sm" data-cancel>Cancel</button> <button type="submit" class="ds-btn gold sm">Reply</button> </span> </div> </form> </template> </div> <!-- ================= SIDEBAR ================= --> <aside class="fx-side" aria-label="About this post"> <section class="fx-panel" aria-labelledby="info-h"> <h2 id="info-h">This post</h2> <dl class="fx-meta"> <dt>Category</dt><dd><a href="{{ url_for('discussions_page', c=thread.category) }}">{{ thread.category_name }}</a></dd> <dt>Score</dt><dd>{{ thread.score }}</dd> <dt>Started</dt><dd title="{{ thread.created_display }}">{{ thread.created_display[:10] }}</dd> <dt>Last activity</dt><dd title="{{ thread.bumped_display }}">{{ fm.ago(thread.bumped_ago) }}</dd> </dl> </section> <section class="fx-panel" aria-labelledby="voices-h"> <h2 id="voices-h">{{ thread.voice_count }} voice{{ '' if thread.voice_count == 1 else 's' }}</h2> <ul class="fx-voices"> {% for n in thread.voices %}<li>{{ fm.av(n, official=(thread.is_official and n == thread.author)) }} <span>{{ n }}</span></li>{% endfor %} </ul> </section> {{ fm.rules() }} </aside> </div> </div> {% endif %} </div> {% endblock %} {% block scripts_extra %}{% if thread %}{% include "partials/forum_script.html" %}{% endif %}{% endblock %}