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 %}