Discourse 한국어 번역과 Gitea 배포 구성 완료

일반 화면과 관리자 화면 및 활성 플러그인의 최종 한국어 번역을 반영했다.

기능별 검수 원본과 보드 화면 패치 원본을 저장소에 포함했다. yarn test와 yarn deploy 및 yarn verify:deployment 명령을 추가했다.

활성 플러그인 29개의 번역 키 6,005개에서 누락 0개를 확인했다. PC와 390픽셀 모바일 화면을 확인했다. 운영 app 컨테이너에 재배포하고 HTTP 200 응답을 확인했다.

데이터베이스 구조 변경과 데이터 이전은 없다. 사용자 요청이 없어 백업은 만들지 않았다.
This commit is contained in:
2026-09-27 03:28:42 +00:00
parent cc5042a0a6
commit e0cd349f58
24 changed files with 16518 additions and 25 deletions
+527
View File
@@ -0,0 +1,527 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DMenu from "discourse/float-kit/components/d-menu";
import { eq } from "discourse/truth-helpers";
import DButton from "discourse/ui-kit/d-button";
import DDropdownMenu from "discourse/ui-kit/d-dropdown-menu";
import dIcon from "discourse/ui-kit/helpers/d-icon";
import dDragAndDropAutoScroll from "discourse/ui-kit/modifiers/d-drag-and-drop-auto-scroll";
import dDragAndDropTarget from "discourse/ui-kit/modifiers/d-drag-and-drop-target";
import { i18n } from "discourse-i18n";
import { isRecencyColumn } from "../lib/boards-card-ordering";
import {
columnColorVariable,
hasColumnColor,
} from "../lib/boards-column-helpers";
import { animateCardReorder, captureCardRects } from "../lib/boards-motion";
import BoardsCard from "./boards-card";
import BoardsAddTopicAsCardModal from "./modal/boards-add-topic-as-card";
import BoardsCardDetailModal from "./modal/boards-card-detail";
const RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export function recencyTimestamp(card) {
const value = Date.parse(card?.recency_at || "");
return Number.isFinite(value) ? value : 0;
}
export function recencyDropIndicatorInsertBefore(
cardsContainer,
cardElements,
draggedCardId
) {
return (
cardElements.find((cardEl) => {
const elCardId = parseInt(cardEl.dataset.cardId, 10);
return elCardId !== draggedCardId;
}) || cardsContainer.querySelector(".discourse-boards-column__show-all")
);
}
export function shouldAnimateDropIndicatorPlacement({
hadIndicator,
columnId,
fromColumnId,
hasPlacedIndicator,
}) {
return hadIndicator || columnId !== fromColumnId || hasPlacedIndicator;
}
export default class BoardsColumn extends Component {
@service modal;
@tracked showAllCards = false;
get cardCount() {
return this.args.column.cards?.length || 0;
}
get visibleCards() {
const cards = this.args.column.cards || [];
if (!this.isRecencySorted || this.showAllCards) {
return cards;
}
const cutoff = Date.now() - RECENCY_WINDOW_MS;
return cards.filter(
(card) =>
card.id === this.args.linkedCardId || recencyTimestamp(card) >= cutoff
);
}
get hiddenCardCount() {
if (!this.isRecencySorted || this.showAllCards) {
return 0;
}
return this.cardCount - this.visibleCards.length;
}
get isRecencySorted() {
return isRecencyColumn(this.args.column);
}
get columnTags() {
const allColumns = this.args.allColumns || [];
return allColumns.map((col) => col.tag_name).filter(Boolean);
}
get columnIndex() {
const allColumns = this.args.allColumns || [];
return allColumns.findIndex((col) => col.id === this.args.column.id);
}
get lastColumnIndex() {
return (this.args.allColumns?.length || 0) - 1;
}
@action
showAllOlderCards() {
this.showAllCards = true;
}
@action
async startAddCard(closeMenu) {
await closeMenu();
this.modal.show(BoardsCardDetailModal, {
model: {
card: {},
isNew: true,
board: this.args.board,
onCreateCard: (data) =>
this.args.onAddCard({ ...data, columnId: this.args.column.id }),
},
});
}
@action
async addTopicAsCard(closeMenu) {
await closeMenu();
this.modal.show(BoardsAddTopicAsCardModal, {
model: {
onAddTopicAsCard: (data) =>
this.args.onAddCard({ ...data, columnId: this.args.column.id }),
},
});
}
@action
editColumn(closeMenu) {
closeMenu();
this.args.onEditColumn(this.args.column.id);
}
@action
moveLeft(closeMenu) {
closeMenu();
this.args.onMoveColumn(this.args.column.id, -1);
}
@action
moveRight(closeMenu) {
closeMenu();
this.args.onMoveColumn(this.args.column.id, 1);
}
@action
clearColumn(closeMenu) {
closeMenu();
this.args.onClearColumn(this.args.column.id);
}
@action
deleteColumn(closeMenu) {
closeMenu();
this.args.onDeleteColumn(this.args.column);
}
@action
canDropCard({ source }) {
// Recency columns sort themselves, so reordering within one is meaningless.
return !(
this.isRecencySorted && source.data.fromColumnId === this.args.column.id
);
}
@action
dragOver({ element, location }) {
const dragData = this.args.dragData;
if (!dragData) {
return;
}
element.classList.add("discourse-boards-column--drag-target");
const cardsContainer = element.querySelector(
".discourse-boards-column__cards"
);
if (!cardsContainer) {
return;
}
const { clientY } = location.current.input;
let indicator = cardsContainer.querySelector(
".discourse-boards-column__drop-indicator"
);
const hadIndicator = !!indicator;
if (!indicator) {
indicator = document.createElement("div");
indicator.className = "discourse-boards-column__drop-indicator";
}
indicator.style.height = `${dragData.cardHeight}px`;
const cardElements = [
...cardsContainer.querySelectorAll(".discourse-boards-card"),
];
let insertBefore = null;
if (this.isRecencySorted) {
insertBefore = recencyDropIndicatorInsertBefore(
cardsContainer,
cardElements,
dragData.cardId
);
} else {
for (const cardEl of cardElements) {
const elCardId = parseInt(cardEl.dataset.cardId, 10);
if (elCardId === dragData.cardId) {
continue;
}
const rect = cardEl.getBoundingClientRect();
if (clientY <= rect.top + rect.height / 2) {
insertBefore = cardEl;
break;
}
}
}
const emptyMsg = cardsContainer.querySelector(
".discourse-boards-column__empty"
);
if (emptyMsg) {
emptyMsg.hidden = true;
}
if (
this.#indicatorMatchesPosition(cardsContainer, indicator, insertBefore)
) {
return;
}
const shouldAnimate = shouldAnimateDropIndicatorPlacement({
hadIndicator,
columnId: this.args.column.id,
fromColumnId: dragData.fromColumnId,
hasPlacedIndicator: dragData.hasPlacedIndicator,
});
const previousRects = shouldAnimate
? captureCardRects(cardsContainer, {
skipCardIds: [dragData.cardId],
})
: null;
indicator.classList.remove(
"discourse-boards-column__drop-indicator--source"
);
if (insertBefore) {
cardsContainer.insertBefore(indicator, insertBefore);
} else {
cardsContainer.appendChild(indicator);
}
if (shouldAnimate) {
animateCardReorder(cardsContainer, previousRects, {
skipCardIds: [dragData.cardId],
});
}
dragData.hasPlacedIndicator = true;
}
@action
dragLeave({ element }) {
element.classList.remove("discourse-boards-column--drag-target");
this.removeDropIndicator(element, { animate: true });
}
@action
drop({ element, location }) {
element.classList.remove("discourse-boards-column--drag-target");
const dragData = this.args.dragData;
if (!dragData) {
this.removeDropIndicator(element, { animate: false });
return;
}
const cardsContainer = element.querySelector(
".discourse-boards-column__cards"
);
const { clientY } = location.current.input;
let afterCardId = null;
if (cardsContainer && !this.isRecencySorted) {
const cardElements = [
...cardsContainer.querySelectorAll(".discourse-boards-card"),
];
for (const cardEl of cardElements) {
const elCardId = parseInt(cardEl.dataset.cardId, 10);
if (elCardId === dragData.cardId) {
continue;
}
const rect = cardEl.getBoundingClientRect();
if (clientY > rect.top + rect.height / 2) {
afterCardId = elCardId;
}
}
}
// The board viewer removes the placeholder once the move settles.
this.args.onDrop(
dragData.cardId,
this.args.column.id,
afterCardId,
dragData.fromColumnId
);
}
removeDropIndicator(columnEl, { animate = false } = {}) {
const cardsContainer = columnEl.querySelector(
".discourse-boards-column__cards"
);
const indicator = columnEl.querySelector(
".discourse-boards-column__drop-indicator"
);
const dragData = this.args.dragData;
if (!indicator) {
columnEl
.querySelector(".discourse-boards-column__empty")
?.removeAttribute("hidden");
return;
}
const previousRects =
animate && cardsContainer
? captureCardRects(cardsContainer, {
skipCardIds: dragData ? [dragData.cardId] : [],
})
: null;
indicator.remove();
const emptyMsg = columnEl.querySelector(".discourse-boards-column__empty");
if (emptyMsg) {
emptyMsg.hidden = false;
}
if (cardsContainer && previousRects) {
animateCardReorder(cardsContainer, previousRects, {
skipCardIds: dragData ? [dragData.cardId] : [],
});
}
}
#indicatorMatchesPosition(cardsContainer, indicator, insertBefore) {
if (indicator.parentElement !== cardsContainer) {
return false;
}
if (insertBefore) {
return indicator.nextElementSibling === insertBefore;
}
return indicator === cardsContainer.lastElementChild;
}
<template>
<div
class="discourse-boards-column
{{if
(hasColumnColor @column.color)
'discourse-boards-column--has-color'
}}"
data-column-id={{@column.id}}
data-default-sort={{@column.default_sort}}
style={{columnColorVariable @column.color}}
{{dDragAndDropTarget
accepts="boards-card"
canDrop=this.canDropCard
indicator=false
onDrag=this.dragOver
onDragEnter=this.dragOver
onDragLeave=this.dragLeave
onDrop=this.drop
}}
>
<div class="discourse-boards-column__header">
<span class="discourse-boards-column__header-content">
<span class="discourse-boards-column__title">
{{#if @column.icon}}{{dIcon @column.icon}}{{/if}}
{{@column.fancyTitle}}
</span>
<span class="discourse-boards-column__count">
{{this.cardCount}}
</span>
</span>
{{#if @board.canManage}}
<DMenu
@icon="ellipsis"
@identifier="boards-column-controls"
@title="열 메뉴"
@triggerClass="btn-flat btn-small discourse-boards-column__menu-trigger"
>
<:content as |args|>
<DDropdownMenu as |dropdown|>
<dropdown.item>
<DButton
class="btn-transparent discourse-boards-column__menu-edit"
@action={{fn this.editColumn args.close}}
@icon="pencil"
@label="boards.board.edit_column"
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-transparent discourse-boards-column__menu-move-left"
@action={{fn this.moveLeft args.close}}
@disabled={{eq this.columnIndex 0}}
@icon="arrow-left"
@label="boards.board.move_left"
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-transparent discourse-boards-column__menu-move-right"
@action={{fn this.moveRight args.close}}
@disabled={{eq this.columnIndex this.lastColumnIndex}}
@icon="arrow-right"
@label="boards.board.move_right"
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-transparent discourse-boards-column__menu-clear"
@action={{fn this.clearColumn args.close}}
@disabled={{eq this.cardCount 0}}
@icon="xmark"
@label="boards.board.clear_column"
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-transparent btn-danger discourse-boards-column__menu-delete"
@action={{fn this.deleteColumn args.close}}
@icon="trash-can"
@label="boards.board.delete_column"
/>
</dropdown.item>
</DDropdownMenu>
</:content>
</DMenu>
{{/if}}
</div>
<div
class="discourse-boards-column__cards"
{{dDragAndDropAutoScroll types="boards-card"}}
>
{{#if this.visibleCards.length}}
{{#each this.visibleCards key="id" as |card|}}
<BoardsCard
@allSameCategory={{@allSameCategory}}
@board={{@board}}
@card={{card}}
@columnColor={{@column.color}}
@columnIcon={{@column.icon}}
@columnTags={{this.columnTags}}
@columnTitle={{@column.fancyTitle}}
@isDropHighlighted={{eq @dropHighlightCardId card.id}}
@isLinkHighlighted={{eq @linkHighlightCardId card.id}}
@isPendingDrop={{eq @pendingDropCardId card.id}}
@onDeleteCard={{@onDeleteCard}}
@onDragEnd={{@onDragEnd}}
@onDragStart={{@onDragStart}}
@onPromoteToTopic={{fn @onPromoteToTopic card.id}}
@onRefreshBoard={{@onRefreshBoard}}
@onUpdateCard={{@onUpdateCard}}
/>
{{/each}}
{{else if (eq this.cardCount 0)}}
<div class="discourse-boards-column__empty">
{{i18n "boards.board.no_cards"}}
</div>
{{/if}}
{{#if this.hiddenCardCount}}
<button
class="discourse-boards-column__show-all"
type="button"
{{on "click" this.showAllOlderCards}}
>
{{i18n "boards.board.show_older_cards" count=this.hiddenCardCount}}
</button>
{{/if}}
</div>
{{#if @board.canWrite}}
<div class="discourse-boards-column__footer">
<DMenu
@icon="plus"
@identifier="boards-column-add"
@label={{i18n "boards.board.add_card"}}
@triggerClass="discourse-boards-column__add-btn"
>
<:content as |args|>
<DDropdownMenu as |dropdown|>
<dropdown.item>
<DButton
class="btn-transparent"
@action={{fn this.startAddCard args.close}}
@icon="plus"
@label="boards.board.add_card"
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-transparent"
@action={{fn this.addTopicAsCard args.close}}
@icon="link"
@label="boards.board.add_topic_as_card"
/>
</dropdown.item>
</DDropdownMenu>
</:content>
</DMenu>
</div>
{{/if}}
</div>
</template>
}