Files
syskiller e0cd349f58 Discourse 한국어 번역과 Gitea 배포 구성 완료
일반 화면과 관리자 화면 및 활성 플러그인의 최종 한국어 번역을 반영했다.

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

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

데이터베이스 구조 변경과 데이터 이전은 없다. 사용자 요청이 없어 백업은 만들지 않았다.
2026-09-27 03:28:42 +00:00

528 lines
15 KiB
Plaintext

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