generated from ttipo/project-base
feat(frontend): 목록을 대화로 묶어 보게 한다 (14단계 화면 3차)
메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의 한 줄을 더한다. 켜면 목록 출처가 GET /api/conversations 이고, 줄에 하는 일 (고르기 · 읽음 · 중요 · 이동 · 삭제)은 그 줄의 messageIds 에서 붙잡힌 메일을 뺀 메일을 지금의 여러 통 API 로 보낸다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(9단계). - 대화 목록에서는 messages 가 비므로 읽기 화면 · 삭제 · 예약 취소가 줄 기준 도우미(rowCount · rowIndexOfMessage · isListedMessage 등)로 같은 규칙을 탄다 - 첫 진입은 이 브라우저가 그 직원에게서 마지막으로 안 값으로 열고, 설정을 받지 못해도 [대화로 묶기]를 쓸 수 있다 - 리뷰 M1: 로그아웃 · 세션 만료 직후 설정 reset 이 watcher 를 깨워 떠나는 화면의 목록을 다시 부르던 회귀 — canUseApp 이 false 면 부르지 않고 reset 은 보이는 값을 뒤집지 않는다 - 리뷰 L7: 버린 목록 요청은 AbortController 로 끊는다(엔진 방식은 한 요청이 15초까지) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 대화 줄 (14단계 — 디자인 원본 ListThreads14) — 2줄: 참여자 · 통 수(회색 "4통" — 한 통이면 없다) · 마지막 시각 / 마지막 메일 제목(+ 첨부)
|
||||
// - 하나라도 안 읽었으면 굵게(뱃지 없이 — 메일 줄과 같다), 중요 표시한 메일이 있으면 별이 찬다. 누르면 그 메일함의 마지막 메일을 연다(아래에 같은 대화 칸)
|
||||
// - 고르기 칸 · Ctrl(⌘) / Shift + 클릭 · 별은 메일 줄(MessageListItem)과 같은 자리 · 크기다 — 하는 일은 그 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어)
|
||||
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
|
||||
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
|
||||
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { ConversationSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
row: ConversationSummary
|
||||
/** 이름 자리 — 참여자 (useConversationRows) */
|
||||
who: RowWho
|
||||
/** 누르면 갈 곳 — 그 메일함의 마지막 메일(임시보관함은 쓰기 화면) */
|
||||
to: RouteLocationRaw
|
||||
/** 하나라도 안 읽었다 (임시보관함은 늘 false) */
|
||||
unread?: boolean
|
||||
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
|
||||
draft?: boolean
|
||||
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
|
||||
held?: boolean
|
||||
/** 지금 열려 있는 메일이 든 줄 */
|
||||
selected?: boolean
|
||||
/** 고른 줄 (여러 통 처리) */
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 줄에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', rowId: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', rowId: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
unread: false,
|
||||
draft: false,
|
||||
held: false,
|
||||
selected: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
|
||||
const countText = computed(() => conversationCountText(props.row))
|
||||
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
|
||||
const time = computed(() => pendingLabel.value ?? formatMailListTime(props.row.latest.receivedAt))
|
||||
|
||||
/** Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다 (메일 줄과 같다). 고를 수 없는 줄은 고르지 않는다 */
|
||||
function onMainClick(event: MouseEvent) {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
|
||||
event.preventDefault()
|
||||
if (!props.held) emit('check', props.row.id, event.shiftKey)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
|
||||
function onStar() {
|
||||
if (!props.held) emit('toggle-flag', props.row.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'thread-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
]"
|
||||
:data-row-id="row.id"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="thread-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
:unavailable="held"
|
||||
:note="SEND_LATER_TEXT.heldNotSelectable"
|
||||
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
|
||||
/>
|
||||
<RouterLink
|
||||
class="thread-row__main"
|
||||
:to="to"
|
||||
:data-message-id="row.latest.id"
|
||||
@click="onMainClick"
|
||||
>
|
||||
<span class="thread-row__top">
|
||||
<span :class="['thread-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="countText"
|
||||
class="thread-row__count"
|
||||
data-thread-count
|
||||
>{{ countText }}</span>
|
||||
<time
|
||||
:class="['thread-row__time', { 'is-pending': pendingLabel }]"
|
||||
:datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<span class="thread-row__bottom">
|
||||
<span :class="['thread-row__subject', { 'is-placeholder': draft && !row.latest.subject }]">{{ subject }}</span>
|
||||
<AppIcon
|
||||
v-if="row.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
:label="MAIL_LIST_ROW_TEXT.attachment"
|
||||
class="thread-row__clip"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
:class="['thread-row__star', { 'is-flagged': row.isFlagged }]"
|
||||
:aria-label="MAIL_LIST_ROW_TEXT.flag"
|
||||
:aria-pressed="row.isFlagged"
|
||||
:aria-disabled="held ? 'true' : undefined"
|
||||
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
|
||||
data-thread-star
|
||||
@click="onStar"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="15"
|
||||
:filled="row.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일 줄(MessageListItem)과 같은 자리 · 크기 — 2줄 약 50px (디자인 원본 ListThreads14 · Main) */
|
||||
.thread-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.thread-row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.thread-row.is-selected {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.thread-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.thread-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
/* 고르기 칸 — 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 (메일 줄과 같다) */
|
||||
.thread-row__check {
|
||||
position: absolute;
|
||||
width: var(--space-1);
|
||||
height: var(--space-1);
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외 --size-row-check)에 16px 상자 */
|
||||
.thread-row:hover .thread-row__check,
|
||||
.thread-row:focus-within .thread-row__check,
|
||||
.thread-row.is-checking .thread-row__check {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
width: var(--size-row-check);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
.thread-row__check:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
.thread-row__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--size-list-row);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
|
||||
line-height: var(--line-height-heading);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thread-row:hover .thread-row__main,
|
||||
.thread-row:focus-within .thread-row__main,
|
||||
.thread-row.is-checking .thread-row__main {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
.thread-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.thread-row__main:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* 첫 줄 — 참여자(넘치면 줄인다) · 통 수 · 시각(오른쪽 끝) (디자인 원본 ListThreads14) */
|
||||
.thread-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-row__who {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 통 수 — 회색 글, 뱃지 · 알약 없음 */
|
||||
.thread-row__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thread-row__time {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thread-row__time.is-pending,
|
||||
.thread-row.is-unread .thread-row__time.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.thread-row__bottom {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-row__subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-row__clip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 줄과 같다) */
|
||||
.thread-row__who.is-placeholder,
|
||||
.thread-row__subject.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 (메일 줄과 같다) */
|
||||
.thread-row.is-unread .thread-row__who {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-row.is-unread .thread-row__subject,
|
||||
.thread-row.is-unread .thread-row__time {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-row__star {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-touch);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.thread-row__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.thread-row__star[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.thread-row__star:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 대화 줄(38) — 고르기 · 별 · 참여자(+ 통 수) · 제목(링크 + 첨부 클립) · 날짜 (14단계 — 보드 ListThreads14 에 표가 없어 정했다)
|
||||
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
|
||||
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
|
||||
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
|
||||
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
|
||||
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { ConversationSummary } from '@/shared/types/mail'
|
||||
import { formatMailTableDate } from '@/shared/utils/format'
|
||||
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
row: ConversationSummary
|
||||
who: RowWho
|
||||
to: RouteLocationRaw
|
||||
unread?: boolean
|
||||
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
|
||||
draft?: boolean
|
||||
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
|
||||
held?: boolean
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'open', location: RouteLocationRaw): void
|
||||
(e: 'toggle-flag', rowId: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', rowId: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
unread: false,
|
||||
draft: false,
|
||||
held: false,
|
||||
checked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
|
||||
const countText = computed(() => conversationCountText(props.row))
|
||||
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('button')) return
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (!props.held) emit('check', props.row.id, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (target?.closest('a')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
emit('open', props.to)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
|
||||
function onStar() {
|
||||
if (!props.held) emit('toggle-flag', props.row.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="row.id"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
|
||||
<MailCheckbox
|
||||
class="thread-table-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
:unavailable="held"
|
||||
:note="SEND_LATER_TEXT.heldNotSelectable"
|
||||
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mail-table__star">
|
||||
<button
|
||||
type="button"
|
||||
:class="['thread-table-row__star', { 'is-flagged': row.isFlagged }]"
|
||||
:aria-label="MAIL_LIST_ROW_TEXT.flag"
|
||||
:aria-pressed="row.isFlagged"
|
||||
:aria-disabled="held ? 'true' : undefined"
|
||||
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
|
||||
data-thread-star
|
||||
@click="onStar"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="row.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="thread-table-row__who-cell">
|
||||
<div class="thread-table-row__who-line">
|
||||
<span :class="['thread-table-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="countText"
|
||||
class="thread-table-row__count"
|
||||
data-thread-count
|
||||
>{{ countText }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="thread-table-row__subject-line">
|
||||
<RouterLink
|
||||
:class="['thread-table-row__link', { 'is-placeholder': draft && !row.latest.subject }]"
|
||||
:to="to"
|
||||
:data-message-id="row.latest.id"
|
||||
>
|
||||
{{ subject }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="row.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
:label="MAIL_LIST_ROW_TEXT.attachment"
|
||||
class="thread-table-row__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
:class="['mail-table__date', { 'is-pending': pendingLabel }]"
|
||||
:title="pendingLabel ?? undefined"
|
||||
>
|
||||
<time :datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt">{{
|
||||
pendingLabel ?? formatMailTableDate(row.latest.receivedAt)
|
||||
}}</time>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일 표의 행(MessageTableRow)과 같은 높이 · 바탕 (디자인 원본 MainList · BulkSelect) */
|
||||
.thread-table-row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread-table-row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.thread-table-row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.thread-table-row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.thread-table-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.thread-table-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
.thread-table-row__check {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.thread-table-row__star {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.thread-table-row__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.thread-table-row__star[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 참여자 칸 — 이름이 먼저 줄고 통 수는 남는다 */
|
||||
.thread-table-row__who-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-table-row__who {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.thread-table-row__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thread-table-row__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-table-row__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thread-table-row__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.thread-table-row__clip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 표와 같다) */
|
||||
.thread-table-row__who.is-placeholder,
|
||||
.thread-table-row__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.thread-table-row.is-unread .thread-table-row__who,
|
||||
.thread-table-row.is-unread .thread-table-row__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-table-row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-table-row .mail-table__date.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
|
||||
// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수".
|
||||
// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -32,16 +34,18 @@ const listHref = computed(() => {
|
||||
const source = mailStore.listSource
|
||||
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
|
||||
})
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.rowIndexOfMessage(props.messageId))
|
||||
/**
|
||||
* 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수.
|
||||
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다
|
||||
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다.
|
||||
* 대화 목록은 대화 수 — 모르면 다 받았을 때만 불러온 줄 수 (메일함의 통 수는 대화 수가 아니다)
|
||||
*/
|
||||
const total = computed<number | null>(() => {
|
||||
const known = mailStore.pageInfo?.total ?? null
|
||||
if (known !== null) return known
|
||||
if (mailStore.search) return null
|
||||
if (mailStore.isConversationList) return mailStore.hasMore ? null : mailStore.rowCount
|
||||
return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length
|
||||
})
|
||||
const position = computed(() =>
|
||||
@@ -51,18 +55,17 @@ const hasPrev = computed(() => index.value > 0)
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */
|
||||
const hasNext = computed(
|
||||
() =>
|
||||
index.value !== -1 &&
|
||||
(index.value < mailStore.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore),
|
||||
index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore),
|
||||
)
|
||||
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
|
||||
const nextWaiting = computed(
|
||||
() => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing,
|
||||
() => index.value !== -1 && mailStore.rowMessageIdAt(index.value + 1) === null && mailStore.isRefreshing,
|
||||
)
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
const previous = mailStore.rowMessageIdAt(index.value - 1)
|
||||
const source = mailStore.listSource
|
||||
if (previous && source) await showSiblingIn(source, previous.id)
|
||||
if (previous && source) await showSiblingIn(source, previous)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,19 +75,19 @@ async function goPrev() {
|
||||
async function goNext() {
|
||||
if (loadingNext.value || nextWaiting.value || index.value === -1) return
|
||||
const current = index.value
|
||||
if (!mailStore.messages[current + 1] && (mailStore.hasMore || mailStore.isLoadingMore)) {
|
||||
if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) {
|
||||
loadingNext.value = true
|
||||
try {
|
||||
const waited = mailStore.isLoadingMore
|
||||
await mailStore.loadMore()
|
||||
if (waited && !mailStore.messages[current + 1] && mailStore.hasMore) await mailStore.loadMore()
|
||||
if (waited && mailStore.rowMessageIdAt(current + 1) === null && mailStore.hasMore) await mailStore.loadMore()
|
||||
} finally {
|
||||
loadingNext.value = false
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
const next = mailStore.rowMessageIdAt(current + 1)
|
||||
const source = mailStore.listSource
|
||||
if (next && source) await showSiblingIn(source, next.id)
|
||||
if (next && source) await showSiblingIn(source, next)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
@@ -94,7 +97,8 @@ function onBackToList(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
const source = mailStore.listSource
|
||||
if (!source) return
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
// 대화 목록은 줄의 링크가 마지막 메일을 가리킨다 — 그 줄로 초점을 돌려준다
|
||||
viewStore.setReturnFocus(mailStore.rowMessageIdAt(index.value) ?? props.messageId)
|
||||
void backToSourceList(source)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 오른쪽의 [대화로 묶기] (14단계 — 디자인 원본 ListThreads14): 눌림 단추(aria-pressed) · 높이 36(--size-notice-action — 안내 줄 단추의
|
||||
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(groupByConversation)을 PATCH 한다 — 화면을 먼저 바꾸고(메일함 화면이 바로 묶거나 푼다)
|
||||
// 끝내 실패하면 서버가 확인한 값으로 되돌린다(mail-settings.store — 칸마다 저장은 한 번에 하나, 알림은 인터셉터).
|
||||
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(36×36 — 이름은 title · 화면 낭독, MailboxEmptyButton 과 같은 결), 목록 보기는 아이콘 + 이름.
|
||||
// 켠 모양은 보기 전환의 고른 칸과 같다(청록 옅은 바탕 · 진한 청록 글자 — 테두리는 그대로, 보드의 #9FD3D7 테두리는 새 색이라 두지 않았다)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
interface Props {
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
const pressed = computed(() => settingsStore.groupByConversation)
|
||||
const iconOnly = computed(() => props.variant === 'split')
|
||||
|
||||
function toggle() {
|
||||
void settingsStore.update('groupByConversation', !pressed.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['group-toggle', { 'is-on': pressed, 'group-toggle--icon': iconOnly }]"
|
||||
:aria-pressed="pressed"
|
||||
:title="iconOnly ? MAIL_CONVERSATION_LIST_TEXT.toggle : undefined"
|
||||
data-group-toggle
|
||||
@click="toggle"
|
||||
>
|
||||
<AppIcon
|
||||
name="message-square"
|
||||
:size="14"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span :class="{ 'visually-hidden': iconOnly }">{{ MAIL_CONVERSATION_LIST_TEXT.toggle }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 높이 36(밀도 예외 --size-notice-action) · 테두리 · 12.5px 굵게 (디자인 원본 ListThreads14) */
|
||||
.group-toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-toggle:hover:not(.is-on) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.group-toggle.is-on {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 분할 보기 — 아이콘만, 정사각형 36 */
|
||||
.group-toggle--icon {
|
||||
width: var(--size-notice-action);
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐)
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -75,7 +76,7 @@ const retentionNotice = computed(() => {
|
||||
const role = mailbox.value?.role ?? null
|
||||
const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null
|
||||
if (text === null) return null
|
||||
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
|
||||
return mailStore.listStatus === 'ready' && mailStore.rowCount === 0 ? null : text
|
||||
})
|
||||
/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */
|
||||
const searchConditions = computed(() =>
|
||||
@@ -97,15 +98,17 @@ const singleChar = computed(
|
||||
const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing)
|
||||
/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */
|
||||
const searchPending = computed(
|
||||
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.messages.length === 0 && mailStore.hasMore,
|
||||
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.rowCount === 0 && mailStore.hasMore,
|
||||
)
|
||||
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
|
||||
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
|
||||
|
||||
function retry() {
|
||||
const current = search.value
|
||||
const source = mailStore.listSource
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions })
|
||||
else if (scheduled.value) void mailStore.openScheduled()
|
||||
else if (source?.kind === 'conversations') void mailStore.openConversations(source.mailboxId)
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
|
||||
@@ -240,7 +243,7 @@ watch(
|
||||
@leave="leave"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<template v-else-if="mailStore.rowCount > 0">
|
||||
<slot />
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](14단계) · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 개수(14단계 — 디자인 원본 ListThreads14): 묶지 않았으면 "메일 9통, 안 읽은 메일 2통"(메일함의 통 수), 묶었으면 "대화 6개, 안 읽은 메일 2통"
|
||||
// (대화 수는 목록의 total — 모르면 그 부분을 뺀다). 안 읽은 메일이 없으면 뒤를 뺀다.
|
||||
// [대화로 묶기]는 메일함 목록(묶었든 아니든)에서만 — 검색 결과 · 예약됨은 묶지 않는다. 설정을 받는 동안은 숨기고(그사이 고른 것은 받기가 덮는다),
|
||||
// 받았거나 받지 못했으면 보인다 — 받지 못했으면 짐작 위에서 고르고 그 칸만 저장한다 (mail-settings.store — 리뷰 L4)
|
||||
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
|
||||
@@ -7,12 +11,15 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailConversationToggle from '@/components/mail/MailConversationToggle.vue'
|
||||
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isScheduledSource } from '@/shared/utils/mail-list-source'
|
||||
import { listHeadCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { isScheduledSource, mailboxListIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +33,7 @@ withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
|
||||
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
|
||||
@@ -36,9 +44,17 @@ const countText = computed(() => {
|
||||
}
|
||||
const box = mailStore.currentMailbox
|
||||
if (!box) return ''
|
||||
const total = `전체 ${formatCount(box.totalCount)}`
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
if (mailStore.isConversationList) {
|
||||
return listHeadCountText({ grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount })
|
||||
}
|
||||
return listHeadCountText({ grouped: false, total: box.totalCount, unread: box.unreadCount })
|
||||
})
|
||||
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (14단계 — 받는 중 · 받기 전에는 숨긴다) */
|
||||
const groupable = computed(
|
||||
() =>
|
||||
mailboxListIdOf(mailStore.listSource) !== null &&
|
||||
(settingsStore.status === 'ready' || settingsStore.status === 'error'),
|
||||
)
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
|
||||
const emptiable = computed(() => {
|
||||
@@ -49,14 +65,24 @@ const emptiable = computed(() => {
|
||||
|
||||
<template>
|
||||
<div :class="['list-header', `list-header--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="list-header__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<div class="list-header__heading">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="list-header__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<!-- 분할 보기 칸에서는 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다 (글은 그대로 있어 화면 낭독은 다 읽는다) -->
|
||||
<span
|
||||
class="list-header__count"
|
||||
:title="countText || undefined"
|
||||
>{{ countText }}</span>
|
||||
</div>
|
||||
<div class="list-header__end">
|
||||
<MailConversationToggle
|
||||
v-if="groupable"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailboxEmptyButton
|
||||
v-if="emptiable"
|
||||
:mailbox="emptiable"
|
||||
@@ -92,7 +118,22 @@ const emptiable = computed(() => {
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
}
|
||||
|
||||
/*
|
||||
* 이름 · 개수 — 오른쪽 단추들이 남긴 자리를 쓴다. 좁으면 개수가 먼저 줄고(사라질 때까지) 그다음에 이름이 준다
|
||||
* (14단계 — [대화로 묶기]가 더해져 분할 보기 칸에서 이름이 1px 모자라 "색인 …"으로 잘렸다)
|
||||
*/
|
||||
.list-header__heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
@@ -105,7 +146,7 @@ const emptiable = computed(() => {
|
||||
|
||||
/* 좁으면 이름보다 먼저 줄어든다 */
|
||||
.list-header__count {
|
||||
flex: 0 100 auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
|
||||
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
|
||||
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
@@ -16,6 +17,7 @@ import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -70,7 +72,10 @@ const rovingTool = computed<ToolId>(() => {
|
||||
|
||||
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const countText = computed(() => {
|
||||
const count = formatCount(mailStore.checkedCount)
|
||||
return mailStore.isConversationList ? MAIL_CONVERSATION_LIST_TEXT.selected(count) : MAIL_BULK_TEXT.selected(count)
|
||||
})
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
@@ -231,7 +236,7 @@ watch(disabledTools, (disabled) => {
|
||||
</div>
|
||||
<MailPurgeDialog
|
||||
:open="purgeOpen"
|
||||
:count="mailStore.checkedCount"
|
||||
:count="mailStore.checkedActionIds.length"
|
||||
id-prefix="bulk-purge"
|
||||
@confirm="onPurgeConfirm"
|
||||
@cancel="purgeOpen = false"
|
||||
|
||||
@@ -60,7 +60,7 @@ const title = computed(() => {
|
||||
return count === null ? MAIL_CONVERSATION_TEXT.title : MAIL_CONVERSATION_TEXT.titleCount(formatCount(count))
|
||||
})
|
||||
const traceCode = computed(() => formatTraceCode(mailStore.conversationError?.traceId))
|
||||
const listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.messages.map((message) => message.id)))
|
||||
const listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.listedSummaries.map((message) => message.id)))
|
||||
const rows = computed(() =>
|
||||
conversationRowsOf(mailStore.conversationItems, {
|
||||
currentId: props.messageId,
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
|
||||
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
|
||||
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
|
||||
// 대화로 묶은 메일함(14단계 — 디자인 원본 ListThreads14)은 한 줄이 대화 하나다 — 줄 규칙은 useConversationRows, 줄은 ConversationListItem
|
||||
import ConversationListItem from '@/components/mail/ConversationListItem.vue'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import { useConversationRows } from '@/composables/useConversationRows'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const rows = useMailListRows()
|
||||
const conversationRows = useConversationRows()
|
||||
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
@@ -24,21 +28,40 @@ function onCheck(id: string, range: boolean) {
|
||||
title-id="message-list-title"
|
||||
>
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
:search="Boolean(mailStore.search)"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
<template v-if="mailStore.isConversationList">
|
||||
<ConversationListItem
|
||||
v-for="row in mailStore.conversations"
|
||||
:key="row.id"
|
||||
:row="row"
|
||||
:who="conversationRows.whoOf(row, 'split')"
|
||||
:to="conversationRows.locationOf(row)"
|
||||
:unread="conversationRows.isUnread(row)"
|
||||
:draft="conversationRows.isDraft.value"
|
||||
:held="conversationRows.isHeld(row)"
|
||||
:selected="conversationRows.isSelected(row)"
|
||||
:checked="mailStore.checkedIds.has(row.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
@toggle-flag="conversationRows.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
:search="Boolean(mailStore.search)"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
@@ -215,7 +215,7 @@ async function onBlock(target: string, moveToJunk: boolean) {
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const outcome = await senderBlock.block(message, target, moveToJunk)
|
||||
if (!outcome.moved || outcome.stale) return
|
||||
const removed = !mailStore.messages.some((item) => item.id === message.id)
|
||||
const removed = !mailStore.isListedMessage(message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,10 +93,10 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
|
||||
it('머리에 "메일 N통, 안 읽은 메일 M통"(14단계 — 디자인 원본 ListThreads14) 과 보기 전환이 있다', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('메일 15통, 안 읽은 메일 2통')
|
||||
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,23 +3,36 @@
|
||||
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
|
||||
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
|
||||
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
|
||||
// 대화로 묶은 메일함(14단계 — 보드 ListThreads14 에 표가 없어 정했다)은 한 줄이 대화 하나다(ConversationTableRow):
|
||||
// 고르기 · 별 · 보낸 사람(참여자 + 통 수) · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import ConversationTableRow from '@/components/mail/ConversationTableRow.vue'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
|
||||
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
|
||||
import { useConversationRows } from '@/composables/useConversationRows'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { whoColumnLabel } from '@/shared/utils/mail-list-row'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const rows = useMailListRows()
|
||||
const conversationRows = useConversationRows()
|
||||
|
||||
const caption = computed(() =>
|
||||
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
|
||||
)
|
||||
/** 대화로 묶은 메일함 — 크기 칸이 없고 보낸 사람 칸에 참여자를 보인다 */
|
||||
const threads = computed(() => mailStore.isConversationList)
|
||||
const caption = computed(() => {
|
||||
if (mailStore.search) return MAIL_SEARCH_TEXT.title
|
||||
const name = mailStore.currentMailbox?.name ?? '메일함'
|
||||
return threads.value ? MAIL_CONVERSATION_LIST_TEXT.tableCaption(name) : `${name} 메일 목록`
|
||||
})
|
||||
/** 보낸 사람 칸 머리 — 대화는 참여자(보낸 사람들)라 어느 메일함이든 "보낸 사람"(받은편지함의 칸 이름과 같다) */
|
||||
const whoHeader = computed(() => (threads.value ? whoColumnLabel(null, false) : rows.whoHeader.value))
|
||||
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
@@ -40,7 +53,7 @@ function open(location: RouteLocationRaw) {
|
||||
>
|
||||
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value, 'mail-table--threads': threads }]">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
@@ -54,26 +67,48 @@ function open(location: RouteLocationRaw) {
|
||||
>
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
<col
|
||||
v-if="!threads"
|
||||
class="mail-table__col-size"
|
||||
>
|
||||
</colgroup>
|
||||
<MessageTableHead
|
||||
:who-header="rows.whoHeader.value"
|
||||
:who-header="whoHeader"
|
||||
:mixed="rows.mixed.value"
|
||||
:threads="threads"
|
||||
/>
|
||||
<tbody>
|
||||
<MessageTableRow
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
@open="open"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
<template v-if="threads">
|
||||
<ConversationTableRow
|
||||
v-for="row in mailStore.conversations"
|
||||
:key="row.id"
|
||||
:row="row"
|
||||
:who="conversationRows.whoOf(row, 'table')"
|
||||
:to="conversationRows.locationOf(row)"
|
||||
:unread="conversationRows.isUnread(row)"
|
||||
:draft="conversationRows.isDraft.value"
|
||||
:held="conversationRows.isHeld(row)"
|
||||
:checked="mailStore.checkedIds.has(row.id)"
|
||||
@open="open"
|
||||
@toggle-flag="conversationRows.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<MessageTableRow
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
@open="open"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -171,6 +206,13 @@ function open(location: RouteLocationRaw) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 대화로 묶은 메일함 — 크기 칸이 없어 날짜 칸이 마지막 칸이다 (14단계 — 1100px 미만은 아래 규칙이 같은 폭을 준다) */
|
||||
@media (min-width: 68.75rem) {
|
||||
.mail-table--threads .mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
|
||||
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
|
||||
// 대화로 묶은 메일함(14단계)은 불러온 대화 모두 고르기이고 크기 칸이 없다
|
||||
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -12,10 +14,13 @@ interface Props {
|
||||
whoHeader: string
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
|
||||
mixed?: boolean
|
||||
/** 대화로 묶은 메일함 — 크기 칸이 없다 */
|
||||
threads?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
threads: false,
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
@@ -33,7 +38,7 @@ const mailStore = useMailStore()
|
||||
class="mail-table-head__check"
|
||||
:checked="mailStore.checkState === 'all'"
|
||||
:mixed="mailStore.checkState === 'some'"
|
||||
:label="MAIL_BULK_TEXT.selectAll"
|
||||
:label="threads ? MAIL_CONVERSATION_LIST_TEXT.selectAll : MAIL_BULK_TEXT.selectAll"
|
||||
@toggle="mailStore.toggleCheckAll()"
|
||||
/>
|
||||
</th>
|
||||
@@ -66,6 +71,7 @@ const mailStore = useMailStore()
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
v-if="!threads"
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user