Tag-Reveal-Komponente in Themenlisten – Tags in Themenlisten erweitern/zusammenklappen

Hinweis: Bevor ich dies in Theme-Komponenten poste, wollte ich zuerst Feedback erhalten, ob diese Theme-Komponente qualifiziert ist oder ob es größere Probleme damit gibt.

:warning: Offenlegung: Diese Theme-Komponente wurde mit Hilfe von KI-Codierungswerkzeugen geplant, implementiert und getestet.

Ich würde mich über Ihr Feedback freuen!


:information_source: Zusammenfassung Tag Reveal
:eyeglasses: Vorschau Nicht verfügbar…
:hammer_and_wrench: Repository GitHub - jrgong420/discourse-tag-reveal
:question: Installationsanleitung So installieren Sie ein Theme oder eine Theme-Komponente
:open_book: Neu bei Discourse Themes? Anfängerleitfaden zur Verwendung von Discourse Themes

Discourse Tag Reveal ist eine leichtgewichtige Theme-Komponente, die Topic-Listen übersichtlich hält, indem sie nur die ersten N Tags pro Topic anzeigt und den Rest durch einen zugänglichen Umschalter „+X weitere Tags“ ersetzt. Benutzer können erweitern, um alle Tags anzuzeigen, und wieder einklappen, um die gekürzte Ansicht zu erhalten. Es funktioniert sofort mit Discurses Standard-Tag-UI und erfordert keine serverseitigen Änderungen.

Funktionen

  • Konfigurierbares Tag-Limit (Standard: 5) über Theme-Einstellungen

  • Umschalter im Tag-Stil, tastaturzugänglich (Enter/Leertaste) mit ARIA-Attributen

  • Lokalisierte Zeichenfolgen mit themePrefix und discourse-i18n

  • SPA-sicheres Verhalten: setzt Logik bei Seitenwechseln zurück und wendet sie neu an

  • Unterstützt unendliches Scrollen über MutationObserver

  • Minimales CSS; respektiert Kern-Tag-Stile

  • Keine Template-Überschreibungen oder Plugin-Abhängigkeiten

Screenshots / Demo

…kommt bald

Installation & Konfiguration

  • Getestet mit Discourse-Version: 3.6.0beta1

  • Konfigurieren Sie die Einstellungen im Tab „Einstellungen“ der Komponente:

  • max_tags_visible (Integer, Standard 5): Wie viele Tags vor dem Einklappen angezeigt werden sollen

  • toggle_tag_style: Visueller Stil des Umschalters, der dem Tag-Erscheinungsbild entspricht (derzeit ist nur der „Box“-Stil implementiert)

  • Geltungsbereich: Betrifft Topic-Listen (Neueste, Neu, Ungelesen und Kategorie-Topic-Listen)

Kompatibilität mit anderen Theme-Komponenten

:warning: Nur minimale Tests durchgeführt, bitte testen Sie selbst, bevor Sie es in der Produktion einsetzen

Hinweise

  • Stellen Sie sicher, dass das Tagging aktiviert ist (Admin → Einstellungen → Tags), andernfalls sehen Sie keine Auswirkungen.

  • Wenn Ihre Website das Tag-CSS stark anpasst, möchten Sie möglicherweise die .ts-toggle-Stile anpassen, um eine perfekte visuelle Ausrichtung zu erzielen.

Ideen für die Zukunft

Ich plane nicht wirklich, weitere Funktionen zu implementieren, bin aber gerne bereit, PRs anzunehmen. Einige Ideen für die Zukunft:

  • Aktivieren/Deaktivieren für Tags in der Topic-Ansicht

  • Granulare Steuerung für bestimmte Seiten und/oder Kategorien

2 „Gefällt mir“

Haben Sie die Einstellung absichtlich genauso benannt wie eine Einstellung im Kern? Ich wäre besorgt über Missverständnisse.

1 „Gefällt mir“

Guter Fang! Habe es gerade geändert…

2 „Gefällt mir“

Sieht wirklich interessant aus. Ich werde es später in meiner Entwicklungsumgebung ausprobieren, da es im Theme Creator nicht zu funktionieren scheint (es sei denn, ich mache etwas falsch?) :thinking:.

Klingt interessant! Können Sie ein paar Screenshots oder Bildschirmaufnahmen der Funktion in Aktion teilen?

:smiley: Habe eine kurze Video-Demo im ersten Beitrag hinzugefügt, siehe hier:

Ich habe noch nicht einmal geprüft, wie ich meine TC-Komponente dort einreichen/hinzufügen kann…:smiley:
Aber auf jeden Fall möchte ich hier zuerst Feedback sammeln, und sobald es bereit ist, in Theme component veröffentlicht zu werden, werde ich sehen, wie ich es dort hinzufügen kann.

3 „Gefällt mir“

Theme Creator verwendet nicht den Box-Stil

Sie möchten vielleicht Folgendes verwenden:

more_tags:
  one: "+%{count} weiterer Tag"
  other: "+%{count} weitere Tags"
1 „Gefällt mir“

Guter Punkt. Ich habe vergessen, die Standardbeschriftung in +%{count} mehr zu ändern, um sie kurz und bündig zu halten. So verwenden wir sie und halten die Dinge kompakt und sauber.

1 „Gefällt mir“

Hallo,

Diese Funktion könnte in einigen Situationen interessant sein!

Auf den ersten Blick gibt es ein paar Dinge zu beachten:

  • Theme-Einstellungen und Site-Einstellungen sind nicht dasselbe. Sie müssen zuerst den Dienst abrufen, um auf max_tags_per_topic zugreifen zu können, z. B.: const siteSettings = api.container.lookup("service:site-settings");

  • Die zusätzlichen Prüfungen, um das Limit zu ermitteln, sollten nicht notwendig sein; Sie können den Wert direkt abrufen. Sie können wahrscheinlich Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic ) verwenden.

  • Sie stellen die Sichtbarkeit von Trennzeichen nicht wieder her.

  • Möglicherweise möchten Sie die Ereignisse abmelden.

  • Der Prozess beim erstmaligen Laden sollte mit MutationObserver nicht notwendig sein. Normalerweise möchten Sie, bevor Sie global werden, zuerst prüfen, ob es eine Möglichkeit gibt, den Geltungsbereich um das Element herum mithilfe der API (z. B. Plugin-Outlet) zu reduzieren.

Ich werde prüfen, ob es eine andere Möglichkeit gibt!

1 „Gefällt mir“

Da es sich in der api-initializers-Datei befindet, würde @service siteSettings auch funktionieren?

Können Sie jetzt nachsehen? Der letzte Commit sollte die angesprochenen Punkte behoben haben.

Die minimale Discourse-Version 3.6.0 bedeutet, dass es eine ganze Weile dauern wird, bis es jemand nutzen kann. Meinten Sie 3.5.0 oder 3.6.0beta1?

Ich meinte 3.6.0beta1, das ist die Version, mit der ich sie getestet habe …

Sie verwenden dies in einer Klasse. Es funktioniert sonst nicht.

Sie möchten dann 3.6.0.beta1 schreiben, sonst kann es im Moment niemand installieren.

Ich habe ein wenig nachgesehen. Tatsächlich gibt es keine einfache Möglichkeit, dies zu erreichen; ich habe jedoch eine interessante und vereinfachte Methode gefunden, dies mit der API zu tun.

  • Sie verwendet das Topic-Modell, um zu ändern, welche sichtbaren Tags ausgegeben werden, bevor die Vorlage generiert wird. Das bedeutet keine DOM-Manipulation und ist einstellungsunabhängig. Abhängig vom Zustand (revealTags) gibt sie die ursprüngliche Liste oder eine teilweise Liste zurück.

  • Um den Umschaltknopf zu erstellen, verwendet sie die API, um ein Tag mit dem HTML eines Knopfes hinzuzufügen (leider gibt es hier keinen Plugin-Outlet). Das Klickereignis wird separat behandelt. Beim Klicken wird der Umschaltzustand aktualisiert (revealTags) und wir lösen ein erneutes Rendern der Tag-Liste aus.

Der große Vorteil dieser Methode ist, dass Sie sich nicht mit dem HTML herumschlagen und herausfinden müssen, was basierend auf den verschiedenen Stilen mit CSS angezeigt/verborgen werden soll.

chrome_lSKqwYt5Z7

Ich teile hier meinen Testcode:

import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import { computed } from "@ember/object";

export default apiInitializer((api) => {
  const siteSettings = api.container.lookup("service:site-settings");

  const maxVisibleTags = Math.min(
    settings.max_tags_visible,
    siteSettings.max_tags_per_topic
  );

  let topicModels = {};

  api.modifyClass(
    "model:topic",
    (Superclass) =>
      class extends Superclass {
        revealTags = false;

        init() {
          super.init(...arguments);
          topicModels[this.id] = this;
        }

        @computed("tags")
        get visibleListTags() {
          if (this.revealTags) {
            return super.visibleListTags;
          }
          return super.visibleListTags.slice(0, maxVisibleTags);
        }
      }
  );

  api.addTagsHtmlCallback(
    (topic, params) => {
      if (topic.tags.length <= maxVisibleTags) {
        return "";
      }

      const isExpanded = topic.revealTags;
      const label = isExpanded
        ? i18n(themePrefix("js.tag_reveal.hide"))
        : i18n(themePrefix("js.tag_reveal.more_tags"), {
            count: topic.tags.length - maxVisibleTags,
          });

      return `<a class="reveal-tag-action" role="button" aria-expanded="${isExpanded}">${label}</a>`;
    },
    {
      priority: siteSettings.max_tags_per_topic + 1,
    }
  );

  document.addEventListener("click", (event) => {
    const target = event.target;
    if (!target?.matches(".reveal-tag-action")) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    const element =
      target.closest("[data-topic-id]") ||
      document.querySelector("h1[data-topic-id]");
    const topicId = element?.dataset.topicId;
    if (!topicId) {
      return;
    }

    const topicModel = topicModels[topicId];
    if (!topicModel) {
      return;
    }

    topicModel.revealTags = !topicModel.revealTags;
    topicModel.notifyPropertyChange("tags");
  });
});
.reveal-tag-action {
  background-color: var(--primary-50);
  border: 1px solid var(--primary-200);
  color: var(--primary-800);
  font-size: small;
  padding-inline: 3px;
}

.discourse-tags__tag-separator:has(+ .reveal-tag-action) {
  visibility: hidden;
}

2 „Gefällt mir“

Hallo Leute, ich habe ein weiteres Update hochgeladen und zusätzliche experimentelle Funktionen hinzugefügt („Featured“-Tags, die immer zuerst kommen und nicht zur maximalen Anzahl gezählt werden + Highlight-Themenzeile in der Themenlistenansicht). Daher ändert sich der gesamte TC ein wenig mit erweiterten Funktionen, um bestimmte basierend auf konfigurierten Tabs hervorzuheben.

@Arkshine Danke, dass du deine vereinfachte Methode geteilt hast, ich weiß sie wirklich zu schätzen!!!

Es hat auch die Einzelansicht von Themen beeinflusst, daher haben wir eine Einstellung hinzugefügt, um dieses Verhalten manuell zu aktivieren. Mit der neuen Methode bleibt der erweiterte Zustand beim Navigieren zu einer anderen Route/Seite erhalten, aber das habe ich noch nicht behoben.

Ich habe es in diesen Branch implementiert:

  • Ich denke, Sie müssen das CSS überprüfen.

    • Sie sollten wahrscheinlich nicht discourse-tag zum Umschaltknopf hinzufügen, es ist kein Tag.
    • Verwenden Sie auch nicht die Klasse box dafür, es ruiniert den Listenstil.
    • Die Einstellung toggle_tag_style hat nur den Wert „box“, vielleicht könnten Sie „none“ hinzufügen, damit es besser in Listen-/Aufzählungsstile passt.
    • Beginnen Sie einfach und Sie können es nach Belieben anpassen.
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* Versteckt den letzten Trennstrich vor dem Umschaltknopf */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    Box-Stil in den Website- und Theme-Einstellungen:
    chrome_raXs2Gc1sd

    Ich werde mehr Feedback zu dem CSS geben, das Sie einfügen. Zeit zum Schlafen für jetzt.

1 „Gefällt mir“

Eigentlich war es beabsichtigt. z.B. das Topic-Voting-Plugin verwendet die Klasse für die Elemente “x Stimmen”.

→ Sieh es in Aktion in der Feature - Discourse Meta Kategorie

Danke, ich werde nachsehen!

Vorerst habe ich nur den Box-Stil implementiert, da dies der Stil ist, den ich in unserer Discourse-Instanz verwende. Ich werde die fehlenden Stile später hinzufügen (PRs sind jedoch willkommen :wink: )

Ich verstehe. Ich denke, es ist sinnvoll, wenn Informationen als Tag angezeigt werden, aber hier ist es ein Button, um weitere Tags anzuzeigen; der Kontext ist für mich anders. Es liegt an Ihnen; ich glaube nicht, dass es so wichtig ist.

Um mit dem Feedback fortzufahren:

  • Die Tag-Liste kann auch an anderen Stellen angezeigt werden, z. B. auf der Kategorienseite, bei Benutzeraktivitäten usw. Ich würde die Einstellung collapse_in_topic_view wahrscheinlich entfernen und entweder eine neue mit spezifischen Routen erstellen oder sie einfach überall aktivieren.
    In meinem Testcode habe ich etwas Ähnliches verwendet, um andere Routen zu ignorieren:
JS
    function isAllowedRoute(routeName) {
        const fullRoutesName = [
          "index",
          "userActivity.topics",
          "userActivity.read",
          ...siteSettings.top_menu.split("|").map((item) => `discovery.${item}`),
        ];

        const partialRoutesName = ["topic."];

        if (
          fullRoutesName.includes(routeName) ||
          partialRoutesName.some((partial) => routeName.startsWith(partial))
        ) {
          return true;
        }

        return false;
      }
    ```
  • Die CSS-Injektion kann durch die API ersetzt werden, um eine Klasse zu topic-list-item und zu einem Tag hinzuzufügen. Dann verschieben Sie das CSS nach common.css.

Zum Beispiel:

JS
```js
import { defaultRenderTag } from "discourse/lib/render-tag";

api.registerValueTransformer(
  "topic-list-item-class",
  ({ value, context }) => {
    if (highlightedTagsSet.size === 0) {
      return value;
    }

    if (context.topic?.tags?.some((tag) => highlightedTagsSet.has(tag))) {
      return [...value, `highlighted-tag__${settings.highlighted_style}`];
    }

    return value;
  }
);

api.replaceTagRenderer((tag, params) => {
  if (highlightedTagsSet.has(tag)) {
    params.extraClass = params.extraClass || "";
    params.extraClass += "highlighted";
  }

  return defaultRenderTag(tag, params);
});
```
CSS
/* Hides the last separator before the toggle button */
.discourse-tags__tag-separator:has(+ .reveal-tag-action) {
  visibility: hidden;
}

.reveal-tag-action {
  color: var(--primary-500);

  &.-box {
    background-color: var(--primary-50);
    outline: 1px solid var(--primary-200);
    padding-inline: 8px;
  }
}

.latest-topic-list-item,
.topic-list-item {
  .discourse-tag.highlighted {
    color: var(--tertiary);
    border-color: var(--tertiary);
    background: color-mix(in srgb, var(--tertiary) 12%, transparent);
    font-weight: 600;
  }
}

  &.highlighted-tag {
    &__left-border {
      border-left: 3px solid var(--tertiary);
      background: color-mix(in srgb, var(--tertiary) 6%, transparent);
      transition: box-shadow 160ms ease;

      &:hover {
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
      }
    }

    &__outline {
      outline: 1px solid var(--tertiary);
      outline-offset: -2px;
      border-radius: 7px;
      background: color-mix(in srgb, var(--tertiary) 5%, transparent);
      box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06);
      transition: background-color 160ms ease;
    }

    &__card {
      border-left: 3px solid var(--tertiary);
      background: var(--tertiary-very-low);
      border-radius: var(--border-radius);
      padding-block: var(--space-2);
      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
      transition: box-shadow 160ms ease;

      &:hover {
        box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1);
      }
    }
  }
}

  ```
  • Sie müssen die aktuelle Route nicht von onPageChange setzen, Sie können sie vom Router aus abrufen.
  • Achten Sie auf die Groß-/Kleinschreibung von Tags. Sie haben Website-Einstellungen, die keine Kleinschreibung erzwingen. Ich denke, es ist am besten, das Tag nicht zu ändern.
  • Zum Zurücksetzen des Zustands können Sie wahrscheinlich onPageChange verwenden.
JS
```js
api.onPageChange((url) => {
    const route = api.container.lookup("service:router").recognize(url);
    if (!isAllowedRoute(route?.name)) {
      return;
    }

    for (const [id, model] of topicModels) {
      if (model && model.revealTags) {
        model.revealTags = false;
        model.notifyPropertyChange("tags");
      }
    }
  });
```
  • Wenn möglich, wäre es großartig, Tests hinzuzufügen.

Hier ist der vollständige Testcode (ich habe andere kleinere Änderungen vorgenommen)

JS
import { computed } from "@ember/object";
import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import { defaultRenderTag } from "discourse/lib/render-tag";
import { service } from "@ember/service";

export default apiInitializer((api) => {
  const siteSettings = api.container.lookup("service:site-settings");
  const router = api.container.lookup("service:router");

  const maxVisibleTags = Math.min(
    settings.max_tags_visible,
    siteSettings.max_tags_per_topic
  );

  const highlightedTagsSet = new Set(settings.highlighted_tags.split("|"));
  const topicModels = new Map();

  function isAllowedRoute(routeName) {
    const fullRoutesName = [
      "index",
      "userActivity.topics",
      "userActivity.read",
      "tag.show",
      ...siteSettings.top_menu.split("|").map((item) => `discovery.${item}`),
    ];

    const partialRoutesName = ["topic."];

    if (
      fullRoutesName.includes(routeName) ||
      partialRoutesName.some((partial) => routeName.startsWith(partial))
    ) {
      return true;
    }

    return false;
  }

  api.modifyClass(
    "model:topic",
    (Superclass) =>
      class extends Superclass {
        @service router;

        revealTags = false;

        init() {
          super.init(...arguments);
          topicModels.set(String(this.id), this);
        }

        willDestroy() {
          super.willDestroy(...arguments);
          topicModels.delete(String(this.id));
        }

        @computed("tags")
        get visibleListTags() {
          const baseTags = super.visibleListTags || [];

          if (!isAllowedRoute(this.router.currentRouteName)) {
            return baseTags;
          }

          const highlightedList = [];
          const regularList = [];

          baseTags.forEach((tag) => {
            if (highlightedTagsSet.has(tag)) {
              highlightedList.push(tag);
            } else {
              regularList.push(tag);
            }
          });

          if (this.revealTags) {
            return [...highlightedList, ...regularList];
          }

          return [...highlightedList, ...regularList.slice(0, maxVisibleTags)];
        }
      }
  );

  api.addTagsHtmlCallback(
    (topic) => {
      if (!isAllowedRoute(topic.router.currentRouteName)) {
        return "";
      }

      const allTags = topic.tags || [];
      if (allTags.length === 0) {
        return "";
      }

      const highlightedCount = allTags.filter((tag) =>
        highlightedTagsSet.has(tag)
      ).length;
      const regularCount = allTags.length - highlightedCount;
      const effectiveLimit =
        highlightedCount + Math.min(regularCount, maxVisibleTags);

      // Only show toggle if there are hidden tags
      if (allTags.length <= effectiveLimit) {
        return "";
      }

      const isExpanded = topic.revealTags;
      const hiddenCount = allTags.length - effectiveLimit;
      const label = isExpanded
        ? i18n(themePrefix("js.tag_reveal.hide"))
        : i18n(themePrefix("js.tag_reveal.more_tags"), {
            count: hiddenCount,
          });

      const classList = ["discourse-tag", "reveal-tag-action"];
      if (settings.toggle_tag_style === "box") {
        classList.push("-box");
      }

      return `<a class="${classList.join(" ")}" role="button" aria-expanded="${isExpanded}">${label}</a>`;
    },
    {
      priority: siteSettings.max_tags_per_topic + 1,
    }
  );

  api.registerValueTransformer(
    "topic-list-item-class",
    ({ value, context }) => {
      if (highlightedTagsSet.size === 0) {
        return value;
      }

      if (context.topic?.tags?.some((tag) => highlightedTagsSet.has(tag))) {
        return [...value, `highlighted-tag__${settings.highlighted_style}`];
      }

      return value;
    }
  );

  api.replaceTagRenderer((tag, params) => {
    let newParams = params;

    if (highlightedTagsSet.has(tag)) {
      newParams = {
        ...params,
        extraClass: [params.extraClass, "highlighted"]
          .filter(Boolean)
          .join(" "),
      };
    }

    return defaultRenderTag(tag, newParams);
  });

  document.addEventListener(
    "click",
    (event) => {
      const target = event.target;
      if (!target?.matches(".reveal-tag-action")) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      const element =
        target.closest("[data-topic-id]") ||
        document.querySelector("h1[data-topic-id]");
      const topicId = element?.dataset.topicId;
      if (!topicId) {
        return;
      }

      const topicModel = topicModels.get(topicId);
      if (!topicModel) {
        return;
      }

      topicModel.revealTags = !topicModel.revealTags;
      topicModel.notifyPropertyChange("tags");
    },
    true
  );

  api.onPageChange((url) => {
    const route = api.container.lookup("service:router").recognize(url);
    if (!isAllowedRoute(route?.name)) {
      return;
    }

    for (const [id, model] of topicModels) {
      if (model && model.revealTags) {
        model.revealTags = false;
        model.notifyPropertyChange("tags");
      }
    }
  });
});