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

1485 lines
40 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 didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { cancel, next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import BackButton from "discourse/components/back-button";
import PermanentlyDeleteConfirmModal from "discourse/components/modal/permanently-delete-confirm";
import DMenu from "discourse/float-kit/components/d-menu";
import DTooltip from "discourse/float-kit/components/d-tooltip";
import bodyClass from "discourse/helpers/body-class";
import { reload } from "discourse/helpers/page-reloader";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { waitForAnimationEnd } from "discourse/lib/animation-utils";
import { bind } from "discourse/lib/decorators";
import { isTesting } from "discourse/lib/environment";
import discourseLater from "discourse/lib/later";
import DiscourseURL from "discourse/lib/url";
import { prefersReducedMotion } from "discourse/lib/utilities";
import Category from "discourse/models/category";
import { or } from "discourse/truth-helpers";
import DAsyncContent from "discourse/ui-kit/d-async-content";
import DButton from "discourse/ui-kit/d-button";
import DDropdownMenu from "discourse/ui-kit/d-dropdown-menu";
import dBoundCategoryLink from "discourse/ui-kit/helpers/d-bound-category-link";
import dDiscourseTags from "discourse/ui-kit/helpers/d-discourse-tags";
import dIcon from "discourse/ui-kit/helpers/d-icon";
import dDragAndDropAutoScroll from "discourse/ui-kit/modifiers/d-drag-and-drop-auto-scroll";
import { i18n } from "discourse-i18n";
import { dragToScroll } from "../lib/boards-auto-scroll";
import {
isRecencyColumn,
sortCardsForColumn,
} from "../lib/boards-card-ordering";
import { loadCategories } from "../lib/boards-categories";
import { boardsBoardConfigureUrl, boardsBoardUrl } from "../lib/boards-urls";
import Board from "../models/board";
import Card from "../models/card";
import Column from "../models/column";
import BoardsColumn from "./boards-column";
import BoardsArchive from "./modal/boards-archive";
import BoardsBoardSettings from "./modal/boards-board-settings";
import BoardsCardDetailModal from "./modal/boards-card-detail";
import BoardsColumnSettings from "./modal/boards-column-settings";
import BoardsConstraintFix from "./modal/boards-constraint-fix";
import BoardsTopicCardDetailModal from "./modal/boards-topic-card-detail";
const onWindowResize = modifier((element, [callback]) => {
const wrappedCallback = () => callback(element);
window.addEventListener("resize", wrappedCallback);
const visualViewport = window.visualViewport;
if (visualViewport) {
visualViewport.addEventListener("resize", wrappedCallback);
}
return () => {
window.removeEventListener("resize", wrappedCallback);
if (visualViewport) {
visualViewport.removeEventListener("resize", wrappedCallback);
}
};
});
const matchLastColumnHeight = modifier((element) => {
const container = element.parentElement;
if (!container) {
return;
}
const update = () => {
const columns = container.querySelectorAll(
":scope > .discourse-boards-column"
);
const last = columns[columns.length - 1];
element.style.height = last ? `${last.offsetHeight}px` : "";
columns.forEach((col) => ro.observe(col));
};
const ro = new ResizeObserver(update);
ro.observe(container);
update();
return () => ro.disconnect();
});
function calcAvailableHeight(element) {
schedule("afterRender", () => {
const vv = window.visualViewport;
const viewportHeight = vv?.height ?? window.innerHeight;
const offsetTop = vv?.offsetTop ?? 0;
const top = element.getBoundingClientRect().top - offsetTop;
const available = viewportHeight - top;
document.documentElement.style.setProperty(
"--discourse-boards-available-height",
`${available}px`
);
});
}
export function shouldRefetchMovedCardPayload(existingCard, cardPayload) {
return !existingCard && !!cardPayload?.topic_id && !cardPayload.topic;
}
export default class BoardsBoardViewer extends Component {
@service appEvents;
@service composer;
@service dialog;
@service messageBus;
@service modal;
@service router;
@service toasts;
@tracked board;
@tracked columns;
@tracked dragData = null;
@tracked dropHighlightCardId = null;
@tracked fullscreen = false;
@tracked linkHighlightCardId = null;
@tracked linkedCardId = null;
@tracked pendingDropCardId = null;
setupMessageBus = modifier((element) => {
const channel = `/boards/${this.board.id}`;
this.messageBus.subscribe(channel, this.onBoardMessage);
const handleKeyboardMove = (event) => {
this.#handleCardMoved(event.detail);
};
element.addEventListener("boards:card-moved", handleKeyboardMove);
return () => {
this.messageBus.unsubscribe(channel, this.onBoardMessage);
element.removeEventListener("boards:card-moved", handleKeyboardMove);
};
});
_promotionAppEventListenersBound = false;
_promotionRouteListenerBound = false;
constructor() {
super(...arguments);
this.board = Board.create(this.args.model.board);
this.columns = this.args.model.columns.map((column) =>
Column.create(column)
);
if (this.args.initialCardId) {
schedule("afterRender", () => {
if (this.isDestroying) {
return;
}
const card = this.#findCard(this.args.initialCardId);
if (card) {
this.#openInitialCardModal(card);
} else {
DiscourseURL.routeTo(boardsBoardUrl(this.board));
}
});
}
if (this.args.highlightCardId) {
schedule("afterRender", () => {
if (this.isDestroying) {
return;
}
this.#focusHighlightCard(this.args.highlightCardId);
});
}
if (this.args.openBoardSettings && this.board.canManage) {
schedule("afterRender", () => {
if (this.isDestroying) {
return;
}
this.#openBoardSettingsModal({ updateUrl: false });
});
}
}
willDestroy() {
super.willDestroy(...arguments);
this._clearDropHighlight();
this._cleanupPromotion();
}
get boardTagNames() {
return this.board.tag_names || [];
}
get hasBoardFilters() {
return this.board.category_ids?.length > 0 || this.boardTagNames.length > 0;
}
get allSameCategory() {
let seenCategory = null;
for (const col of this.columns) {
for (const card of col.cards || []) {
const catId = card.topic?.category_id;
if (catId == null) {
continue;
}
if (seenCategory === null) {
seenCategory = catId;
} else if (seenCategory !== catId) {
return false;
}
}
}
return true;
}
@bind
onBoardMessage(data) {
if (data.client_id && data.client_id === this.messageBus.clientId) {
return;
}
switch (data.type) {
case "board_archived":
case "board_unarchived":
reload();
break;
case "card_created":
this.#handleCardCreated(data.card);
break;
case "card_updated":
this.#handleCardUpdated(data.card);
break;
case "card_moved":
this.#handleCardMoved(data.card);
break;
case "card_deleted":
this.#handleCardDeleted(data.card_id);
break;
case "column_cleared":
this.#handleColumnCleared(data.column_id);
break;
case "columns_reordered":
this.#handleColumnsReordered(data.column_order);
break;
case "board_updated":
this.#handleBoardUpdated();
break;
}
}
@bind
refreshBoard() {
return this.#handleBoardUpdated();
}
@action
toggleFullscreen() {
this.fullscreen = !this.fullscreen;
}
@action
exitFullscreen() {
this.fullscreen = false;
}
@action
onDragStart(data) {
this.dragData = data;
}
@action
onDragEnd(cardId) {
next(this, () => {
if (this.dragData?.cardId === cardId) {
this.dragData = null;
}
});
}
@action
async onDrop(cardId, toColumnId, afterCardId, fromColumnId) {
// A move can wait on a server check or a confirmation. Until it settles,
// keep the card hidden and the placeholder where it was dropped.
this.pendingDropCardId = cardId;
try {
await this.#moveDroppedCard(
cardId,
toColumnId,
afterCardId,
fromColumnId
);
} finally {
this.#settlePendingDrop(cardId);
}
}
@action
async onDeleteCard(cardId) {
const snapshot = this.columns.map((col) =>
col.copy({ cards: [...col.cards] })
);
this.#handleCardDeleted(cardId);
try {
await ajax(`/boards/api/boards/${this.board.id}/cards/${cardId}`, {
type: "DELETE",
data: { client_id: this.messageBus.clientId },
});
} catch (error) {
this.columns = snapshot;
popupAjaxError(error);
}
}
@action
async onUpdateCard(cardId, updates) {
try {
const result = await ajax(
`/boards/api/boards/${this.board.id}/cards/${cardId}`,
{
type: "PUT",
data: { client_id: this.messageBus.clientId, card: updates },
}
);
if (result.card) {
if (result.adopted_floater_id) {
this.columns = this.columns.map((col) =>
col.copy({
cards: col.cards.filter((c) => c.id !== cardId),
})
);
this.#handleCardMoved(result.card);
} else {
this.#handleCardUpdated(result.card);
}
}
} catch (error) {
popupAjaxError(error);
throw error;
}
}
@action
async onAddCard({
topicId,
title,
columnId,
notes,
tag_ids,
assigned_to_name,
}) {
if (topicId) {
try {
const column = this.columns.find(
(candidate) => candidate.id === columnId
);
const constraintFix = await this.#resolveConstraintFix(
topicId,
{ id: topicId },
column
);
if (constraintFix === false) {
return;
}
const data = {
client_id: this.messageBus.clientId,
card: { column_id: columnId, topic_id: topicId },
};
if (constraintFix) {
data.constraint_fix = constraintFix;
}
const result = await ajax(`/boards/api/boards/${this.board.id}/cards`, {
type: "POST",
data,
});
this.#appendCardToColumn(result.card, columnId);
return;
} catch (error) {
if (this.#isTopicNotFoundError(error)) {
await this.#createFallbackFloater(title, columnId);
return;
}
popupAjaxError(error);
return;
}
}
try {
const result = await ajax(`/boards/api/boards/${this.board.id}/cards`, {
type: "POST",
data: {
client_id: this.messageBus.clientId,
card: {
column_id: columnId,
title,
notes,
tag_ids,
assigned_to_name,
},
},
});
this.#appendCardToColumn(result.card, columnId);
} catch (error) {
popupAjaxError(error);
}
}
@action
async onPromoteToTopic(cardId) {
let card;
let column;
for (const col of this.columns) {
const found = col.cards.find((c) => c.id === cardId);
if (found) {
card = found;
column = col;
break;
}
}
if (!card) {
return;
}
this._promotingCardId = cardId;
this.#bindPromotionListeners();
const opts = { title: card.title };
if (card.notes) {
opts.body = card.notes;
}
const tagNames = new Set();
if (card.tags?.length) {
card.tags.forEach((tag) => tagNames.add(tag.name));
}
if (this.board.tag_names?.length) {
this.board.tag_names.forEach((name) => tagNames.add(name));
}
if (column.tag_name) {
tagNames.add(column.tag_name);
}
if (tagNames.size) {
opts.tags = [...tagNames].join(",");
}
const categoryId =
column.move_to_category_id || this.board.category_ids?.[0];
if (categoryId) {
try {
opts.category = await Category.asyncFindById(categoryId);
if (!opts.category) {
this._cleanupPromotion();
this.dialog.alert(i18n("boards.board.errors.category_unavailable"));
return;
}
} catch (error) {
this._cleanupPromotion();
popupAjaxError(error);
return;
}
}
this.composer.openNewTopic(opts);
}
// Column management actions
@action
openAddColumnModal(closeMenu) {
if (typeof closeMenu === "function") {
closeMenu();
}
this.modal.show(BoardsColumnSettings, {
model: {
column: null,
board: this.board,
onSave: (columnData) => this.addColumn(columnData),
},
});
}
@action
openEditColumnModal(columnId) {
const column = this.columns.find((c) => c.id === columnId);
if (!column) {
return;
}
this.modal.show(BoardsColumnSettings, {
model: {
column,
board: this.board,
onSave: (columnData) => this.editColumn(columnId, columnData),
},
});
}
@action
async addColumn(columnData) {
await this._saveColumn(
"POST",
`/boards/api/boards/${this.board.id}/columns`,
{
column: this._serializeColumn(columnData),
}
);
}
@action
async editColumn(columnId, columnData) {
const column = this.columns.find((col) => col.id === columnId);
if (!column) {
return;
}
await this._saveColumn(
"PUT",
`/boards/api/boards/${this.board.id}/columns/${columnId}`,
{ column: this._serializeColumn(column.copy(columnData)) }
);
}
@action
async moveColumn(columnId, direction) {
const index = this.columns.findIndex((c) => c.id === columnId);
if (index === -1) {
return;
}
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= this.columns.length) {
return;
}
const snapshot = this.columns;
const reordered = [...this.columns];
[reordered[index], reordered[newIndex]] = [
reordered[newIndex],
reordered[index],
];
this.columns = reordered;
try {
const result = await ajax(
`/boards/api/boards/${this.board.id}/move-column`,
{
type: "POST",
data: {
column_id: columnId,
direction,
client_id: this.messageBus.clientId,
},
}
);
if (result?.column_order) {
this.#handleColumnsReordered(result.column_order);
}
} catch (error) {
this.columns = snapshot;
popupAjaxError(error);
}
}
@action
async deleteColumn(column) {
const saveColumns = async () => {
try {
return await this._saveColumn(
"DELETE",
`/boards/api/boards/${this.board.id}/columns/${column.id}`
);
} catch (error) {
popupAjaxError(error);
}
};
// No need for confirmation if there are no cards in the column.
if (column.cards.length === 0) {
await saveColumns();
return;
}
// Need a bit of a stronger delete confirmation when getting rid
// of a lot of cards.
if (column.cards.length > 5) {
return this.modal.show(PermanentlyDeleteConfirmModal, {
model: {
message: i18n("boards.board.confirm_delete_column_with_cards", {
count: column.cards.length,
}),
confirmPhrase: column.title,
didConfirm: () => {
saveColumns();
},
},
});
}
this.dialog.confirm({
message: i18n("boards.board.confirm_delete_column"),
didConfirm: saveColumns,
});
}
@action
clearColumn(columnId) {
const column = this.columns.find((col) => col.id === columnId);
const cardCount = column?.cards?.length || 0;
if (cardCount === 0) {
return;
}
this.dialog.confirm({
message: i18n("boards.board.confirm_clear_column", {
count: cardCount,
column_title: column.fancyTitle,
}),
didConfirm: async () => {
const snapshot = this.columns.map((col) =>
col.copy({ cards: [...col.cards] })
);
this.columns = this.columns.map((col) => {
if (col.id === columnId) {
return col.copy({ cards: [] });
}
return col;
});
try {
await ajax(
`/boards/api/boards/${this.board.id}/columns/${columnId}/cards`,
{
type: "DELETE",
data: { client_id: this.messageBus.clientId },
}
);
} catch (error) {
this.columns = snapshot;
popupAjaxError(error);
}
},
});
}
// Board settings actions
@action
openArchiveModal(closeMenu) {
closeMenu();
this.modal.show(BoardsArchive, {
model: {
board: this.board,
onSuccess: async (board) => {
this.toasts.success({
data: {
message: board.archived
? i18n("boards.board.archived_successfully")
: i18n("boards.board.unarchived_successfully"),
},
duration: "short",
});
Object.assign(this.board, board);
await this.#handleBoardUpdated();
DiscourseURL.replaceState(boardsBoardUrl(this.board));
},
},
});
}
@action
openBoardSettings(closeMenu) {
closeMenu();
this.#openBoardSettingsModal();
}
@action
async saveBoardSettings(boardData) {
const payload = {
board: boardData,
};
const originalSlug = this.board.slug;
const result = await ajax(`/boards/api/boards/${this.board.id}`, {
type: "PUT",
contentType: "application/json",
data: JSON.stringify(payload),
});
if (result.board) {
this.board = Board.create({ ...this.board, ...result.board });
}
this.toasts.success({
data: { message: i18n("saved") },
duration: "short",
});
if (this.board.slug && this.board.slug !== originalSlug) {
this.router.replaceWith("boardsBoard", this.board.slug, this.board.id);
}
}
@action
deleteBoard(closeMenu) {
closeMenu?.();
this.dialog.confirm({
message: i18n("boards.manage.confirm_delete"),
didConfirm: async () => {
try {
await ajax(`/boards/api/boards/${this.board.id}`, {
type: "DELETE",
});
this.router.transitionTo("boards");
} catch (error) {
popupAjaxError(error);
}
},
});
}
@action
goToAllBoards(closeMenu) {
closeMenu?.();
this.router.transitionTo("boards");
}
#handleCardCreated(card) {
if (card.topic_id && !card.topic) {
this.#handleBoardUpdated();
return;
}
this.#appendCardToColumn(card, card.column_id);
}
#handleCardUpdated(card) {
this.columns = this.columns.map((col) => {
const cards = col.cards.map((c) => (c.id === card.id ? c.copy(card) : c));
return col.copy({ cards: sortCardsForColumn(col, cards) });
});
}
#handleCardMoved(card) {
const existing = this.#findCard(card.id);
if (shouldRefetchMovedCardPayload(existing, card)) {
this.#handleBoardUpdated();
return;
}
const mergedCard = existing ? existing.copy(card) : Card.create(card);
const withoutCard = this.columns.map((col) =>
col.copy({
cards: col.cards.filter((c) => c.id !== card.id),
})
);
this.columns = withoutCard.map((col) => {
if (col.id === mergedCard.column_id) {
return col.copy({
cards: sortCardsForColumn(col, [...col.cards, mergedCard]),
});
}
return col;
});
}
#handleCardDeleted(cardId) {
this.columns = this.columns.map((col) =>
col.copy({
cards: col.cards.filter((c) => c.id !== cardId),
})
);
}
#handleColumnCleared(columnId) {
this.columns = this.columns.map((col) => {
if (col.id === columnId) {
return col.copy({ cards: [] });
}
return col;
});
}
#handleColumnsReordered(columnOrder) {
const orderMap = new Map(columnOrder.map((id, idx) => [id, idx]));
this.columns = [...this.columns].sort(
(a, b) =>
(orderMap.get(a.id) ?? Infinity) - (orderMap.get(b.id) ?? Infinity)
);
}
async #handleBoardUpdated() {
try {
const result = await ajax(`/boards/api/boards/${this.board.id}.json`);
await Board.preloadCategories(result);
if (result.columns) {
this.columns = result.columns.map((col) =>
Column.create({
...col,
cards: sortCardsForColumn(col, col.cards || []),
})
);
}
Object.assign(this.board, result.board || result);
} catch {
// Board may have been deleted — no action needed
}
}
async #moveDroppedCard(cardId, toColumnId, afterCardId, fromColumnId) {
if (!this.board.canWrite || !fromColumnId) {
return;
}
const fromColumn = this.columns.find((c) => c.id === fromColumnId);
const toColumn = this.columns.find((c) => c.id === toColumnId);
if (!fromColumn || !toColumn) {
return;
}
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
if (cardIndex === -1) {
return;
}
const card = fromColumn.cards[cardIndex];
const isSameColumn = fromColumnId === toColumnId;
const targetIsRecency = isRecencyColumn(toColumn);
if (isSameColumn && targetIsRecency) {
this.dragData = null;
return;
}
if (targetIsRecency) {
afterCardId = null;
}
let constraintFix = null;
if (!isSameColumn && card.topic) {
try {
constraintFix = await this.#resolveConstraintFix(
card.topic_id || card.topic.id,
card.topic,
toColumn
);
} catch (error) {
popupAjaxError(error);
return;
}
if (constraintFix === false) {
return;
}
}
if (!isSameColumn && !constraintFix && this.board.require_confirmation) {
const confirmed = await this._confirmMove(card, toColumn);
if (!confirmed) {
return;
}
}
const snapshot = this.columns.map((col) =>
col.copy({ cards: col.cards.map((c) => c.copy()) })
);
fromColumn.cards.splice(cardIndex, 1);
let insertIndex = targetIsRecency ? 0 : toColumn.cards.length;
if (!targetIsRecency && afterCardId != null) {
const idx = toColumn.cards.findIndex((c) => c.id === afterCardId);
if (idx !== -1) {
insertIndex = idx + 1;
}
} else {
insertIndex = 0;
}
toColumn.cards.splice(insertIndex, 0, card);
card.column_id = toColumnId;
this.#settlePendingDrop(cardId);
this.columns = this.columns.map((col) => {
if (col.id === fromColumnId || col.id === toColumnId) {
return col.copy({ cards: [...col.cards] });
}
return col;
});
this.dragData = null;
const data = {
client_id: this.messageBus.clientId,
card: {
column_id: toColumnId,
after_card_id: afterCardId,
},
};
if (constraintFix) {
data.constraint_fix = constraintFix;
}
try {
const result = await ajax(
`/boards/api/boards/${this.board.id}/cards/${card.id}`,
{ type: "PUT", data }
);
if (result?.card) {
const existingCard = this.columns
.flatMap((col) => col.cards)
.find((c) => c.id === result.card.id);
const mergedCard = existingCard
? existingCard.copy(result.card)
: Card.create(result.card);
const withoutCard = this.columns.map((col) =>
col.copy({
cards: col.cards.filter((c) => c.id !== result.card.id),
})
);
this.columns = withoutCard.map((col) => {
if (col.id === mergedCard.column_id) {
return col.copy({
cards: sortCardsForColumn(col, [...col.cards, mergedCard]),
});
}
return col;
});
}
this._highlightDroppedCard(card.id);
} catch (error) {
this.columns = snapshot;
popupAjaxError(error);
}
}
#settlePendingDrop(cardId) {
if (this.pendingDropCardId !== cardId) {
return;
}
this.pendingDropCardId = null;
document
.querySelectorAll(".discourse-boards-column__drop-indicator")
.forEach((indicator) => indicator.remove());
document
.querySelectorAll(".discourse-boards-column__empty[hidden]")
.forEach((emptyMessage) => (emptyMessage.hidden = false));
}
async #resolveConstraintFix(topicId, topic, column) {
if (!column) {
return null;
}
const response = await ajax(
`/boards/api/boards/${this.board.id}/check-constraint-mismatches`,
{
method: "PUT",
data: {
topic_id: topicId,
target_column_id: column.id,
},
}
);
if (!response.constraints_need_fixing) {
return null;
}
const mismatches = {
needsTags: response.tags_needed.length > 0,
needsCategory: response.categories_needed.length > 0,
boardTagNames: response.tags_needed,
boardCategoryIds: response.categories_needed,
};
const constraintFix = await this._showConstraintFixModal(
topic,
column,
mismatches
);
return constraintFix ?? false;
}
#isTopicNotFoundError(error) {
return error?.jqXHR?.status === 404;
}
#appendCardToColumn(card, columnId) {
if (!card) {
return;
}
card = Card.create(card);
this.columns = this.columns.map((col) => {
if (col.id === columnId) {
return col.copy({
cards: sortCardsForColumn(col, [
...col.cards.filter((c) => c.id !== card.id),
card,
]),
});
}
return col;
});
}
async #createFallbackFloater(title, columnId) {
try {
const result = await ajax(`/boards/api/boards/${this.board.id}/cards`, {
type: "POST",
data: {
client_id: this.messageBus.clientId,
card: { column_id: columnId, title },
},
});
this.#appendCardToColumn(result.card, columnId);
} catch (error) {
popupAjaxError(error);
}
}
#openBoardSettingsModal({ updateUrl = true } = {}) {
const boardUrl = boardsBoardUrl(this.board);
if (updateUrl) {
DiscourseURL.replaceState(boardsBoardConfigureUrl(this.board));
}
this.modal
.show(BoardsBoardSettings, {
model: {
board: this.board,
isNew: false,
onSave: (boardData) => this.saveBoardSettings(boardData),
onDelete: () => this.deleteBoard(),
},
})
.then((result) => {
if (!this.isDestroying) {
DiscourseURL.replaceState(boardUrl);
if (result?.reloadAfterSave) {
reload();
}
}
});
}
#bindPromotionListeners() {
if (!this._promotionAppEventListenersBound) {
this.appEvents.on("topic:created", this, this._onTopicCreated);
this.appEvents.on("composer:cancelled", this, this._cleanupPromotion);
this._promotionAppEventListenersBound = true;
}
if (!this._promotionRouteListenerBound) {
this.router.on("routeWillChange", this._onRouteWillChange);
this._promotionRouteListenerBound = true;
}
}
#unbindPromotionAppEventListeners() {
if (!this._promotionAppEventListenersBound) {
return;
}
this.appEvents.off("topic:created", this, this._onTopicCreated);
this.appEvents.off("composer:cancelled", this, this._cleanupPromotion);
this._promotionAppEventListenersBound = false;
}
#unbindPromotionRouteListener() {
if (!this._promotionRouteListenerBound) {
return;
}
this.router.off("routeWillChange", this._onRouteWillChange);
this._promotionRouteListenerBound = false;
}
#findCard(cardId) {
for (const col of this.columns) {
const card = col.cards.find((c) => c.id === cardId);
if (card) {
return card;
}
}
return null;
}
#focusHighlightCard(cardId) {
if (!this.#findCard(cardId)) {
return;
}
// The deep-linked card stays flagged for the lifetime of the board so a
// card outside its column's recency window keeps rendering; only the pulse
// highlight below is transient.
this.linkedCardId = cardId;
this.linkHighlightCardId = cardId;
// next() rather than afterRender: this can be called mid-afterRender
// flush, before the highlight class renders, and waitForAnimationEnd
// would then see no animation and fall back to resolving immediately.
next(this, async () => {
if (this.isDestroying) {
return;
}
const cardElement = document.querySelector(
`.discourse-boards-card[data-card-id="${cardId}"]`
);
cardElement?.scrollIntoView({ block: "center", inline: "center" });
// With reduced motion there is no animation to wait for; the static
// highlight stays as a persistent indicator instead.
if (!cardElement || isTesting() || prefersReducedMotion()) {
return;
}
await waitForAnimationEnd(cardElement);
if (!this.isDestroying && this.linkHighlightCardId === cardId) {
this.linkHighlightCardId = null;
}
});
}
#openInitialCardModal(card) {
const boardUrl = boardsBoardUrl(this.board);
const isTopicCard = card.card_type === "topic" && card.topic;
const ModalComponent = isTopicCard
? BoardsTopicCardDetailModal
: BoardsCardDetailModal;
let navigatedAway = false;
const column = this.columns.find((col) => col.id === card.column_id);
const model = isTopicCard
? {
card,
columnTitle: column?.fancyTitle,
columnIcon: column?.icon,
columnColor: column?.color,
onNavigateAway: (url) => {
navigatedAway = true;
DiscourseURL.routeTo(url);
},
}
: { card, board: this.board, onUpdateCard: this.onUpdateCard };
this.modal.show(ModalComponent, { model }).finally(() => {
if (!navigatedAway && !this.isDestroying) {
DiscourseURL.replaceState(boardUrl);
}
});
}
_showConstraintFixModal(topic, column, mismatches) {
return new Promise((resolve) => {
this.modal.show(BoardsConstraintFix, {
model: {
topic,
board: this.board,
column,
mismatches,
onConfirm: (result) => resolve(result),
onCancel: () => resolve(null),
},
});
});
}
_confirmMove(card, toColumn) {
return new Promise((resolve) => {
const cardTitle = card.fancyTitle || "";
this.dialog.yesNoConfirm({
message: i18n("boards.board.move_confirm", {
topic_title: cardTitle,
column_title: toColumn.fancyTitle,
}),
didConfirm: () => resolve(true),
didCancel: () => resolve(false),
});
});
}
_serializeColumn(col) {
return {
id: col.id,
title: col.title,
icon: col.icon,
color: col.color || null,
default_sort: col.default_sort || "priority",
tag_name: col.tag_name || null,
move_to_category_id: col.move_to_category_id,
move_to_assigned:
col.move_to_assigned === "_user" ? "" : col.move_to_assigned,
move_to_status: col.move_to_status,
};
}
async _saveColumn(type, url, payload = {}) {
await ajax(url, {
type,
contentType: "application/json",
data: JSON.stringify({
...payload,
client_id: this.messageBus.clientId,
}),
});
this.toasts.success({
data: { message: i18n("saved") },
duration: "short",
});
await this.#handleBoardUpdated();
}
@bind
_onTopicCreated(createdPost) {
const cardId = this._promotingCardId;
this._cleanupPromotion();
this.onUpdateCard(cardId, { topic_id: createdPost.topic_id });
}
@bind
_onRouteWillChange(transition) {
if (this._promotingCardId) {
transition.abort();
this.#unbindPromotionRouteListener();
this._promotingCardId = null;
}
}
_cleanupPromotion() {
this._promotingCardId = null;
this.#unbindPromotionAppEventListeners();
this.#unbindPromotionRouteListener();
}
_highlightDroppedCard(cardId) {
this._clearDropHighlight();
this.dropHighlightCardId = null;
schedule("afterRender", () => {
if (this.isDestroying) {
return;
}
this.dropHighlightCardId = cardId;
if (isTesting()) {
return;
}
this._dropHighlightTimeout = discourseLater(
this,
() => {
if (this.dropHighlightCardId === cardId) {
this.dropHighlightCardId = null;
}
this._dropHighlightTimeout = null;
},
1000
);
});
}
_clearDropHighlight() {
if (this._dropHighlightTimeout) {
cancel(this._dropHighlightTimeout);
this._dropHighlightTimeout = null;
}
}
<template>
{{#if this.fullscreen}}
{{bodyClass "discourse-boards-fullscreen"}}
{{/if}}
<div
class="discourse-boards-board-viewer
{{if this.fullscreen 'discourse-boards-board-viewer--fullscreen'}}"
{{this.setupMessageBus}}
{{onWindowResize calcAvailableHeight}}
{{didInsert calcAvailableHeight}}
>
<div class="discourse-boards-board-viewer__header">
<div class="discourse-boards-board-viewer__title-wrapper">
<BackButton @label="boards.board.all_boards" @route="boards" />
<h2 class="discourse-boards-board-viewer__title">
{{#if this.board.archived}}
<DTooltip>
<:trigger>
{{dIcon "box-archive"}}
</:trigger>
<:content>
<span>{{i18n "boards.board.archived_tooltip"}}</span>
</:content>
</DTooltip>
{{/if}}
{{this.board.fancyTitle}}
</h2>
<div class="discourse-boards-board-viewer__metadata">
{{#if this.hasBoardFilters}}
<div class="discourse-boards-board-viewer__constraint">
<DAsyncContent
@asyncData={{loadCategories}}
@context={{this.board.category_ids}}
>
<:content as |categories|>
{{#each categories as |category|}}
{{dBoundCategoryLink category link=true}}
{{/each}}
</:content>
</DAsyncContent>
{{#if this.boardTagNames.length}}
<div class="list-tags">
{{dDiscourseTags null tags=this.boardTagNames}}
</div>
{{/if}}
<DTooltip>
<:trigger>
{{dIcon "circle-info"}}
</:trigger>
<:content>
<span>{{i18n "boards.board.constraint_tooltip"}}</span>
</:content>
</DTooltip>
</div>
{{/if}}
</div>
</div>
<div class="discourse-boards-board-viewer__controls">
{{#if
(or
this.board.canManage
this.board.can_archive
this.board.can_unarchive
)
}}
<DMenu
@icon="ellipsis"
@identifier="boards-board-controls"
@title="보드 메뉴"
@triggerClass="btn-flat"
>
<:content as |args|>
<DDropdownMenu as |dropdown|>
{{#if this.board.canManage}}
<dropdown.item data-identifier="add-column">
<DButton
class="btn-transparent"
@action={{fn this.openAddColumnModal args.close}}
@icon="plus"
@label="boards.board.add_column"
/>
</dropdown.item>
<dropdown.item data-identifier="board-settings">
<DButton
class="btn-transparent"
@action={{fn this.openBoardSettings args.close}}
@icon="gear"
@label="boards.board.board_settings"
/>
</dropdown.item>
{{/if}}
{{#if (or this.board.can_archive this.board.can_unarchive)}}
<dropdown.item data-identifier="archive-board">
<DButton
class="btn-transparent"
@action={{fn this.openArchiveModal args.close}}
@icon="box-archive"
@label={{if
this.board.archived
"boards.board.unarchive_board_menu"
"boards.board.archive_board"
}}
/>
</dropdown.item>
{{/if}}
{{#if this.board.canManage}}
<dropdown.item data-identifier="delete-board">
<DButton
class="btn-transparent btn-danger"
@action={{fn this.deleteBoard args.close}}
@icon="trash-can"
@label="boards.board.delete_board"
/>
</dropdown.item>
{{/if}}
</DDropdownMenu>
</:content>
</DMenu>
{{/if}}
{{#if this.fullscreen}}
<DButton
class="btn-flat discourse-boards-board-viewer__exit-fullscreen"
@action={{this.exitFullscreen}}
@icon="discourse-compress"
@title="boards.board.exit_fullscreen"
/>
{{else}}
<DButton
class="btn-flat"
@action={{this.toggleFullscreen}}
@icon="discourse-expand"
@title="boards.board.fullscreen"
/>
{{/if}}
</div>
</div>
{{#if this.columns.length}}
<div
class="discourse-boards-board-container"
{{dDragAndDropAutoScroll axis="horizontal" types="boards-card"}}
{{dragToScroll}}
>
{{#each this.columns key="id" as |column|}}
<BoardsColumn
@allColumns={{this.columns}}
@allSameCategory={{this.allSameCategory}}
@board={{this.board}}
@column={{column}}
@dragData={{this.dragData}}
@dropHighlightCardId={{this.dropHighlightCardId}}
@linkedCardId={{this.linkedCardId}}
@linkHighlightCardId={{this.linkHighlightCardId}}
@onAddCard={{this.onAddCard}}
@onClearColumn={{this.clearColumn}}
@onDeleteCard={{this.onDeleteCard}}
@onDeleteColumn={{this.deleteColumn}}
@onDragEnd={{this.onDragEnd}}
@onDragStart={{this.onDragStart}}
@onDrop={{this.onDrop}}
@onEditColumn={{this.openEditColumnModal}}
@onMoveColumn={{this.moveColumn}}
@onPromoteToTopic={{this.onPromoteToTopic}}
@onRefreshBoard={{this.refreshBoard}}
@onUpdateCard={{this.onUpdateCard}}
@pendingDropCardId={{this.pendingDropCardId}}
/>
{{/each}}
{{#if this.board.canManage}}
<button
class="discourse-boards-board-container__add-column"
title={{i18n "boards.board.add_column"}}
type="button"
{{on "click" this.openAddColumnModal}}
{{matchLastColumnHeight}}
>
{{dIcon "plus"}}
</button>
{{/if}}
</div>
{{else}}
<div class="discourse-boards-board-viewer__empty">
<div class="discourse-boards-board-viewer__empty-column">
{{dIcon "table-columns"}}
<h3>{{i18n "boards.board.empty_board"}}</h3>
{{#if this.board.canManage}}
<p>{{i18n "boards.board.empty_board_cta"}}</p>
<DButton
class="btn-primary"
@action={{this.openAddColumnModal}}
@icon="plus"
@label="boards.board.add_column"
/>
{{/if}}
</div>
</div>
{{/if}}
</div>
</template>
}