jrgong
(jrgong)
10월 18, 2025, 11:33오전
1
참고: 이 내용을 테마 컴포넌트 섹션에 게시하기 전에, 이 테마 컴포넌트가 자격을 갖추는지 또는 주요한 문제가 없는지 먼저 피드백을 듣고 싶었습니다.
공개: 이 테마 컴포넌트는 AI 코딩 도구의 도움을 받아 계획, 구현 및 테스트되었습니다.
피드백을 들려주시면 좋겠습니다!
Discourse Tag Reveal은 토픽 목록을 깔끔하게 유지하도록 돕는 가벼운 테마 컴포넌트입니다. 각 토픽당 첫 번째 N개의 태그만 표시하고 나머지는 접근 가능한 “+X개의 추가 태그” 토글로 대체합니다. 사용자는 모든 태그를 보려면 확장하고, 줄어든 보기로 되돌리려면 접을 수 있습니다. 이는 디스커스의 표준 태그 UI와开箱即用(즉시 사용 가능)하며 서버 측 변경이 필요하지 않습니다.
기능
테마 설정을 통한 설정 가능한 태그 제한 (기본값: 5)
태그처럼 스타일링된 토글, 키보드 접근성(Enter/Space) 및 ARIA 속성 지원
themePrefix와 discourse-i18n을 사용한 로컬라이즈된 문자열
SPA 안전 동작: 페이지 변경 시 리셋 및 로직 재적용
MutationObserver를 통한 무한 스크롤 지원
최소한의 CSS; 핵심 태그 스타일 준수
템플릿 오버라이드 또는 플러그인 의존성 없음
비디오 데모:
설치 및 설정
테스트된 디스커스 버전: 3.6.0beta1
컴포넌트의 설정 탭에서 설정을 구성합니다:
max_tags_visible (정수, 기본값 5): 접기 전에 표시할 태그 수
toggle_tag_style: 태그 외형에 맞게 토글의 시각적 스타일을 설정합니다 (현재 “box” 스타일만 구현됨)
범위: 토픽 목록(최신, 새 글, 안 읽은 글, 카테고리 토픽 목록)에 영향을 미칩니다
기타 테마 컴포넌트와의 호환성
최소한의 테스트만 수행되었으므로, 프로덕션 환경에 배포하기 전에 직접 테스트해 주세요.
참고 사항
태그 기능이 활성화되어 있는지 확인하세요(관리자 → 설정 → 태그). 그렇지 않으면 어떤 효과도 볼 수 없습니다.
사이트에서 태그 CSS를 크게 커스터마이징한 경우, 완벽한 시각적 정렬을 위해 .ts-toggle 스타일을 조정할 수 있습니다.
향후 아이디어
더 많은 기능을 구현할 계획은 없지만, PR을 환영합니다. 향후에 대한 몇 가지 아이디어:
Moin
10월 18, 2025, 12:01오후
2
jrgong:
max_tags_per_topic
고의적으로 코어의 설정과 동일한 이름을 붙인 건가요? 오해가 생길까 걱정됩니다.
정말 흥미롭네요. Theme Creator에서는 작동하지 않는 것 같아서(혹시 제가 뭔가 잘못하고 있는 건가요? .) 나중에 제 개발 환경에서 시도해 볼게요.
mcwumbly
(Dave McClure)
10월 18, 2025, 4:10오후
5
흥미롭네요! 해당 기능이 작동하는 모습의 스크린샷이나 화면 녹화를 몇 개 공유해 주시겠어요?
jrgong
(jrgong)
10월 18, 2025, 5:31오후
6
첫 번째 게시물에 짧은 비디오 데모를 추가했습니다. 여기에서 확인하세요:
jrgong:
비디오 데모:
거기서 내 TC 컴포넌트를 제출하거나 추가하는 방법을 아직 확인조차 하지 않았어요…
어쨌든, 먼저 여기서 피드백을 모아보고 #customization:theme-component에 게시할 준비가 되면 그때 추가 방법을 알아볼 생각입니다.
Moin
10월 18, 2025, 5:47오후
7
Theme creator는 태그에 박스 스타일을 사용하지 않습니다.
Moin
10월 18, 2025, 5:58오후
8
jrgong
(jrgong)
10월 18, 2025, 6:24오후
9
좋은 지적입니다. 짧고 간결하게 유지하기 위해 기본 라벨을 +%{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도 작동할까요?
jrgong
(jrgong)
10월 19, 2025, 11:33오전
12
지금 확인해 주실 수 있나요? 최신 커밋에서 지적된 사항들이 수정되었을 것입니다.
Moin
10월 19, 2025, 12:52오후
13
최소 Discourse 버전 3.6.0이라는 것은 누구나 이를 사용할 수 있을 때까지 상당한 시간이 걸릴 것이라는 뜻입니다. 3.5.0이나 3.6.0beta1을 의미하신 건가요?
jrgong
(jrgong)
10월 19, 2025, 12:54오후
14
3.6.0beta1을 말한 건데, 이 버전으로 테스트를 해왔거든요…
클래스 안에서 이 방법을 사용합니다. 그 외에는 작동하지 않습니다.
그렇다면 3.6.0.beta1로 작성해야 합니다. 그렇지 않으면 현재로서는 아무도 설치할 수 없습니다.
Arkshine:
다른 방법이 있는지 확인해 보겠습니다!
조금 찾아보았습니다. 실제로 이를 달성하는 직접적인 방법은 없지만, API를 사용하여 이를 수행하는 흥미롭고 단순화된 방법을 발견했습니다.
토픽 모델을 사용하여 템플릿이 생성되기 전에 출력될 가시적 태그를 변경합니다. 이는 DOM 조작이 필요 없으며 설정에 무관합니다. 상태(revealTags)에 따라 원래 목록 또는 일부 목록을 반환합니다.
토글 버튼을 만들기 위해, 버튼의 HTML을 가진 태그를 추가하는 API를 사용합니다(불행히도 여기에는 플러그인 아웃렛이 없습니다). 클릭 이벤트는 별도로 처리됩니다. 클릭 시 토글 상태(revealTags)가 업데이트되고, 태그 목록의 재렌더링을 트리거합니다.
이 방식의 큰 장점은 HTML을 만지지 않아도 되고, 다양한 스타일에 따라 CSS로 무엇을 표시/숨길지 파악할 필요가 없다는 점입니다.
제 테스트 코드를 여기에 공유합니다:
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;
}
jrgong
(jrgong)
10월 20, 2025, 9:15오전
16
여러분, 또 다른 업데이트를 push하고 실험적 기능 몇 가지를 추가했습니다. (항상 맨 앞에 표시되고 최대 개수 계산에 포함되지 않는 “featured” 태그 + 주제 목록 뷰에서 주제 행 강조) 그래서 전체 TC가 설정된 탭에 따라 특정 항목을 강조하기 위한 더 확장된 기능으로 약간 방향을 바꾸고 있습니다.
@Arkshine 단순화된 메서드를 공유해 주셔서 감사합니다. 정말 감사드립니다!!!
단일 주제 뷰에도 영향을 미쳤기 때문에, 해당 동작을 수동으로 활성화할 수 있는 설정을 추가했습니다. 또한 새로운 메서드로 인해 다른 라우트/페이지로 이동할 때 확장된 상태가 유지되지만, 이 부분은 아직 해결하지 않았습니다.
이 브랜치에 구현했습니다:
https://github.com/jrgong420/discourse-tag-reveal/tree/feature/model-based-tag-reveal
CSS를 검토할 필요가 있을 것 같습니다.
사이트 및 테마 설정의 박스 스타일:
주입하는 CSS에 대해 더 많은 피드백을 드리겠습니다. 지금은 잠잘 시간입니다.
jrgong
(jrgong)
10월 22, 2025, 7:00오전
18
사실 의도적으로 그렇게 했습니다. 예를 들어, 주제 투표 플러그인은 “x votes” 요소에 이 클래스를 사용합니다.
→ Feature - Discourse Meta 카테고리에서 직접 확인해 보세요.
Arkshine:
또한 box 클래스도 사용하지 마세요. 리스트 스타일을 망가뜨립니다. (저는 "-box"를 사용했습니다.)
감사합니다. 확인해 보겠습니다!
Arkshine:
toggle_tag_style 설정에는 “box” 값만 있습니다. 리스트/불릿 스타일에 더 잘 어울리도록 "none"을 추가할 수 있을 것 같습니다.
사용자가 자체적인 커스텀 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");
}
}
});
});