일반 화면과 관리자 화면 및 활성 플러그인의 최종 한국어 번역을 반영했다. 기능별 검수 원본과 보드 화면 패치 원본을 저장소에 포함했다. yarn test와 yarn deploy 및 yarn verify:deployment 명령을 추가했다. 활성 플러그인 29개의 번역 키 6,005개에서 누락 0개를 확인했다. PC와 390픽셀 모바일 화면을 확인했다. 운영 app 컨테이너에 재배포하고 HTTP 200 응답을 확인했다. 데이터베이스 구조 변경과 데이터 이전은 없다. 사용자 요청이 없어 백업은 만들지 않았다.
528 lines
15 KiB
Plaintext
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>
|
|
}
|