피드백 요청: 주제 목록의 태그 공개 컴포넌트 - 주제 목록에서 태그 확장/축소

참고: 이 내용을 테마 컴포넌트 섹션에 게시하기 전에, 이 테마 컴포넌트가 자격을 갖추는지 또는 주요한 문제가 없는지 먼저 피드백을 듣고 싶었습니다.

:warning: 공개: 이 테마 컴포넌트는 AI 코딩 도구의 도움을 받아 계획, 구현 및 테스트되었습니다.

피드백을 들려주시면 좋겠습니다!


:information_source: 요약 태그 리빌 (Tag Reveal)
:eyeglasses: 미리보기 제공되지 않음…
:hammer_and_wrench: 리포지토리 https://github.com/jrgong420/discourse-tag-reveal
:question: 설치 가이드 테마 또는 테마 컴포넌트 설치 방법
:open_book: 디스커스 테마를 처음 사용하시나요? 디스커스 테마 사용 입문가이드

Discourse Tag Reveal은 토픽 목록을 깔끔하게 유지하도록 돕는 가벼운 테마 컴포넌트입니다. 각 토픽당 첫 번째 N개의 태그만 표시하고 나머지는 접근 가능한 “+X개의 추가 태그” 토글로 대체합니다. 사용자는 모든 태그를 보려면 확장하고, 줄어든 보기로 되돌리려면 접을 수 있습니다. 이는 디스커스의 표준 태그 UI와开箱即用(즉시 사용 가능)하며 서버 측 변경이 필요하지 않습니다.

기능

  • 테마 설정을 통한 설정 가능한 태그 제한 (기본값: 5)

  • 태그처럼 스타일링된 토글, 키보드 접근성(Enter/Space) 및 ARIA 속성 지원

  • themePrefix와 discourse-i18n을 사용한 로컬라이즈된 문자열

  • SPA 안전 동작: 페이지 변경 시 리셋 및 로직 재적용

  • MutationObserver를 통한 무한 스크롤 지원

  • 최소한의 CSS; 핵심 태그 스타일 준수

  • 템플릿 오버라이드 또는 플러그인 의존성 없음

비디오 데모:

CleanShot 2025-10-18 at 19.28.39

설치 및 설정

  • 테스트된 디스커스 버전: 3.6.0beta1

  • 컴포넌트의 설정 탭에서 설정을 구성합니다:

  • max_tags_visible (정수, 기본값 5): 접기 전에 표시할 태그 수

  • toggle_tag_style: 태그 외형에 맞게 토글의 시각적 스타일을 설정합니다 (현재 “box” 스타일만 구현됨)

  • 범위: 토픽 목록(최신, 새 글, 안 읽은 글, 카테고리 토픽 목록)에 영향을 미칩니다

기타 테마 컴포넌트와의 호환성

:warning: 최소한의 테스트만 수행되었으므로, 프로덕션 환경에 배포하기 전에 직접 테스트해 주세요.

참고 사항

  • 태그 기능이 활성화되어 있는지 확인하세요(관리자 → 설정 → 태그). 그렇지 않으면 어떤 효과도 볼 수 없습니다.

  • 사이트에서 태그 CSS를 크게 커스터마이징한 경우, 완벽한 시각적 정렬을 위해 .ts-toggle 스타일을 조정할 수 있습니다.

향후 아이디어

더 많은 기능을 구현할 계획은 없지만, PR을 환영합니다. 향후에 대한 몇 가지 아이디어:

  • 토픽 뷰 내 태그에 대한 활성화/비활성화

  • 특정 페이지 및/또는 카테고리에 대한 세밀한 제어

고의적으로 코어의 설정과 동일한 이름을 붙인 건가요? 오해가 생길까 걱정됩니다.

좋은 지적이네요! 방금 수정했습니다…

정말 흥미롭네요. Theme Creator에서는 작동하지 않는 것 같아서(혹시 제가 뭔가 잘못하고 있는 건가요? :thinking:.) 나중에 제 개발 환경에서 시도해 볼게요.

흥미롭네요! 해당 기능이 작동하는 모습의 스크린샷이나 화면 녹화를 몇 개 공유해 주시겠어요?

:smiley: 첫 번째 게시물에 짧은 비디오 데모를 추가했습니다. 여기에서 확인하세요:

거기서 내 TC 컴포넌트를 제출하거나 추가하는 방법을 아직 확인조차 하지 않았어요…:smiley:
어쨌든, 먼저 여기서 피드백을 모아보고 #customization:theme-component에 게시할 준비가 되면 그때 추가 방법을 알아볼 생각입니다.

Theme creator는 태그에 박스 스타일을 사용하지 않습니다.

https://github.com/jrgong420/discourse-tag-reveal/blob/19ea982ff7eda93cb369b2af947dd23b847af961/locales/en.yml#L9-L9

아래와 같이 사용하는 것이 좋습니다.

more_tags:
  one: "+%{count}개의 추가 태그"
  other: "+%{count}개의 추가 태그"

좋은 지적입니다. 짧고 간결하게 유지하기 위해 기본 라벨을 +%{count} more로 변경하는 걸 깜빡했네요. 우리는 이렇게 사용해왔고, 이를 통해 컴팩트하고 깔끔하게 유지하고 있습니다.

안녕하세요,

이 기능은 특정 상황에서 흥미로울 수 있겠네요!

첫인상으로는 몇 가지 주의할 점이 있습니다:

  • 테마 설정과 사이트 설정은 서로 다릅니다. max_tags_per_topic에 액세스하려면 먼저 서비스를 가져와야 합니다. 예를 들어: const siteSettings = api.container.lookup("service:site-settings");

  • 한도를 가져오기 위한 추가적인 체크는 필요하지 않을 수 있습니다. 값을 직접 가져올 수 있습니다. 아마도 Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic )를 사용할 수 있을 것입니다.

  • 구분선의 가시성을 복원하지 않고 있습니다.

  • 이벤트를 등록 해제(unregister)하는 것이 좋을 수 있습니다.

  • 초기 로드 시 처리는 MutationObserver를 사용할 경우 필요하지 않을 수 있습니다. 일반적으로 전역적으로 적용하기 전에, API를 사용하여 해당 요소를 중심으로 범위를 줄일 수 있는 방법이 있는지(예: 플러그인 아웃렛) 먼저 확인하는 것이 좋습니다.

다른 방법이 있는지 확인해 보겠습니다!

api-initializers 파일에 있으므로 @service siteSettings도 작동할까요?

지금 확인해 주실 수 있나요? 최신 커밋에서 지적된 사항들이 수정되었을 것입니다.

최소 Discourse 버전 3.6.0이라는 것은 누구나 이를 사용할 수 있을 때까지 상당한 시간이 걸릴 것이라는 뜻입니다. 3.5.0이나 3.6.0beta1을 의미하신 건가요?

3.6.0beta1을 말한 건데, 이 버전으로 테스트를 해왔거든요…

클래스 안에서 이 방법을 사용합니다. 그 외에는 작동하지 않습니다.

그렇다면 3.6.0.beta1로 작성해야 합니다. 그렇지 않으면 현재로서는 아무도 설치할 수 없습니다.

조금 찾아보았습니다. 실제로 이를 달성하는 직접적인 방법은 없지만, API를 사용하여 이를 수행하는 흥미롭고 단순화된 방법을 발견했습니다.

  • 토픽 모델을 사용하여 템플릿이 생성되기 전에 출력될 가시적 태그를 변경합니다. 이는 DOM 조작이 필요 없으며 설정에 무관합니다. 상태(revealTags)에 따라 원래 목록 또는 일부 목록을 반환합니다.

  • 토글 버튼을 만들기 위해, 버튼의 HTML을 가진 태그를 추가하는 API를 사용합니다(불행히도 여기에는 플러그인 아웃렛이 없습니다). 클릭 이벤트는 별도로 처리됩니다. 클릭 시 토글 상태(revealTags)가 업데이트되고, 태그 목록의 재렌더링을 트리거합니다.

이 방식의 큰 장점은 HTML을 만지지 않아도 되고, 다양한 스타일에 따라 CSS로 무엇을 표시/숨길지 파악할 필요가 없다는 점입니다.

chrome_lSKqwYt5Z7

제 테스트 코드를 여기에 공유합니다:

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

여러분, 또 다른 업데이트를 push하고 실험적 기능 몇 가지를 추가했습니다. (항상 맨 앞에 표시되고 최대 개수 계산에 포함되지 않는 “featured” 태그 + 주제 목록 뷰에서 주제 행 강조) 그래서 전체 TC가 설정된 탭에 따라 특정 항목을 강조하기 위한 더 확장된 기능으로 약간 방향을 바꾸고 있습니다.

@Arkshine 단순화된 메서드를 공유해 주셔서 감사합니다. 정말 감사드립니다!!!

단일 주제 뷰에도 영향을 미쳤기 때문에, 해당 동작을 수동으로 활성화할 수 있는 설정을 추가했습니다. 또한 새로운 메서드로 인해 다른 라우트/페이지로 이동할 때 확장된 상태가 유지되지만, 이 부분은 아직 해결하지 않았습니다.

이 브랜치에 구현했습니다:

https://github.com/jrgong420/discourse-tag-reveal/tree/feature/model-based-tag-reveal

  • CSS를 검토할 필요가 있을 것 같습니다.

    • 토글 버튼에 discourse-tag를 추가하지 않는 것이 좋습니다. 이는 태그가 아니므로요.
    • 또한 box 클래스도 사용하지 마세요. 목록 스타일을 깨뜨립니다(저는 "-box"를 사용했습니다).
    • toggle_tag_style 설정에는 “box” 값만 있습니다. 목록/불릿 스타일에 더 잘 맞도록 "none"을 추가할 수 있을 것 같습니다.
    • 간단하게 시작해서 원하는 대로 조정할 수 있습니다.
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* 토글 버튼 앞의 마지막 구분선을 숨깁니다 */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    사이트 및 테마 설정의 박스 스타일:
    chrome_raXs2Gc1sd

주입하는 CSS에 대해 더 많은 피드백을 드리겠습니다. 지금은 잠잘 시간입니다.

사실 의도적으로 그렇게 했습니다. 예를 들어, 주제 투표 플러그인은 “x votes” 요소에 이 클래스를 사용합니다.

Feature - Discourse Meta 카테고리에서 직접 확인해 보세요.

감사합니다. 확인해 보겠습니다!

사용자가 자체적인 커스텀 CSS를 선호하는 경우를 고려하면, 실제로 좋은 지적입니다.

알겠습니다. 정보를 태그로 표시하는 경우에는 합리적인 것 같지만, 여기서는 더 많은 태그를 표시하기 위한 버튼이므로 맥락이 다르다고 생각합니다. 판단은 당신에게 맡기겠습니다. 크게 중요하지 않다고 생각합니다.

피드백을 이어서 말씀드리겠습니다:

  • 태그 목록은 카테고리 페이지, 사용자 활동 등 다른 장소에도 표시될 수 있습니다. 아마도 collapse_in_topic_view 설정을 제거하고, 특정 라우트에 대한 새로운 설정을 만들거나 모든 곳에 활성화하는 것이 좋겠습니다.
    테스트 코드에서 다른 라우트를 무시하기 위해 다음과 같은 것을 사용했습니다:

    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;
        }
    
  • CSS 주입은 topic-list-item과 태그에 클래스를 추가하는 API를 사용하여 대체할 수 있으며, 그런 다음 CSS를 common.css로 이동시킬 수 있습니다.

    예를 들어:

    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
    /* 토글 버튼 앞의 마지막 구분선을 숨깁니다 */
    .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);
          }
        }
      }
    }
    
    
  • onPageChange에서 현재 라우트를 설정할 필요가 없으며, 라우터에서 액세스할 수 있습니다.

  • 태그의 대소문자에 주의하세요. 소문자를 강제하지 않는 사이트 설정이 있으므로, 태그를 수정하지 않는 것이 가장 좋다고 생각합니다.

  • 상태 재설정과 관련하여, onPageChange를 사용할 수 있을 것입니다.

    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");
          }
        }
      });
    
  • 가능하다면 테스트를 추가하는 것이 좋겠습니다.

다음은 전체 테스트 코드입니다(다른 작은 변경 사항도 포함됨)

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);

      // 숨겨진 태그가 있는 경우에만 토글을 표시합니다
      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");
      }
    }
  });
});