feat(frontend): 대용량 첨부 — 쓰기 · 읽기 · 설정 · 공개 내려받기 화면

- 쓰기: 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). 고르자마자 서버에 만들고
  16MiB 조각(Content-Digest)을 파일 3개까지 함께, 끊기면 2 · 5 · 15 · 30초로 다시 · 1분 넘으면 멈춤 + [다시 시도],
  새로고침 뒤 같은 파일을 다시 고르면 잇는다. 다 올릴 때까지 [보내기]는 aria-disabled + 까닭, 위험 판정은 빨간 줄 + 실시간 알림.
  대용량 파일이 있으면 첫 원문 인용 앞을 data-ara-quote-start · bodyQuoteStart 로 알린다(서버가 그 자리에 덩이를 넣는다).
- 읽기: 첨부 칸의 대용량 항목(서버의 largeFiles), 본문 덩이 접기, 보낸 사람의 받은 수 · 링크 끊기(끊은 날 — revokedAt).
- 설정 › 메일 › 대용량 첨부: 보관 사용량 · 보내지 않은 파일 · 보낸 링크(끊기).
- 공개 화면 /s/:token: 세션 복원 없이(meta.public) · credentials omit · no-referrer, 라우트는 대소문자를 가린다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:55 +09:00
parent 93154ad9a5
commit 3461c361f8
84 changed files with 10084 additions and 137 deletions
@@ -0,0 +1,202 @@
<script setup lang="ts">
// 쓰기 화면의 첨부 칸 — 「메일에 담는 파일」 · 「링크로 보내는 파일」 두 묶음 + 칸 아래 안내 (디자인 원본 ComposeLargeFiles)
// - 링크 파일이 있으면 링크 묶음 머리 "링크로 보내는 파일 N개" + "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지",
// 보관 사용량이 80%를 넘으면 그 오른쪽에 "보관 8.4 / 10 GB"(갈색 굵게). 두 묶음이 함께 있을 때만 메일 묶음 머리("메일에 담는 파일 N개" · "합계 18.1 MB / 25 MB").
// 일반 첨부만 있으면 지금처럼 묶음 이름 없이.
// - 칸 아래 ⓘ 한 줄 — 합계 때문에 저절로 링크로 바꿨을 때(role=status) · 인용 속 원래 링크의 상태(전달 · 답장 — 끝난 링크는 갈색, 보내기는 막지 않는다)
// - 할 일은 쓰기 화면에 넘긴다 — 빼기 · 링크로 보내기 · 멈춤 · 이어 올리기 · 다시 시도 · 파일 고르기
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import ComposeLargeFileItem from '@/components/mail/ComposeLargeFileItem.vue'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import type { LargeUploadItem } from '@/composables/useLargeFileUploads'
import { LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { formatCount } from '@/shared/utils/format'
import type { QuotedLinkNotice } from '@/shared/utils/share-links'
interface Props {
normalItems: UploadItem[]
largeItems: LargeUploadItem[]
/** 메일 묶음 머리의 "합계 18.1 MB / 25 MB" */
normalTotalText: string
/** 링크 묶음 머리의 안내 — "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지" */
linkHint: string
/** 보관 사용량이 많을 때 링크 묶음 머리 오른쪽 — "보관 8.4 / 10 GB" (아니면 null) */
quotaWarning: string | null
/** 합계 때문에 저절로 링크로 바꾼 파일의 안내 (없으면 null) */
switchNotice: string | null
/** 인용 속 원래 링크의 상태 */
quotedNotices: QuotedLinkNotice[]
/** 「링크로 보내기」 단추를 보일 일반 첨부인가 */
linkable: (item: UploadItem) => boolean
}
interface Emits {
(e: 'remove', key: number): void
(e: 'send-as-link', key: number): void
(e: 'large-pause', key: number): void
(e: 'large-resume', key: number): void
(e: 'large-remove', key: number): void
(e: 'large-repick', key: number, file: File): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const normalListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
/** 그 일반 첨부의 빼기 버튼으로 초점을 옮긴다 — 발송이 찾지 못한 전달 원본을 빼게 할 때 쓴다 */
function focusRemove(key: number) {
normalListRef.value?.focusRemove(key)
}
defineExpose({ focusRemove })
</script>
<template>
<div class="attachment-groups">
<template v-if="normalItems.length > 0">
<div
v-if="largeItems.length > 0"
class="attachment-groups__head"
>
<h3 class="attachment-groups__title">
{{ LARGE_FILE_TEXT.normalGroup(formatCount(normalItems.length)) }}
</h3>
<span class="attachment-groups__hint attachment-groups__hint--numeric">{{ normalTotalText }}</span>
</div>
<ComposeAttachmentList
ref="normalListRef"
:items="normalItems"
:linkable="linkable"
@remove="emit('remove', $event)"
@send-as-link="emit('send-as-link', $event)"
/>
</template>
<template v-if="largeItems.length > 0">
<div class="attachment-groups__head">
<h3 class="attachment-groups__title">
{{ LARGE_FILE_TEXT.linkGroup(formatCount(largeItems.length)) }}
</h3>
<span class="attachment-groups__hint">{{ linkHint }}</span>
<span
v-if="quotaWarning"
class="attachment-groups__quota"
data-large-quota
>{{ quotaWarning }}</span>
</div>
<ul class="attachment-groups__list">
<ComposeLargeFileItem
v-for="item in largeItems"
:key="item.key"
:item="item"
@pause="emit('large-pause', $event)"
@resume="emit('large-resume', $event)"
@remove="emit('large-remove', $event)"
@repick="(key: number, file: File) => emit('large-repick', key, file)"
/>
</ul>
</template>
<p
v-if="switchNotice"
class="attachment-groups__notice"
role="status"
data-large-switch
>
<AppIcon
name="info"
:size="15"
class="attachment-groups__notice-icon"
/>
<span>{{ switchNotice }}</span>
</p>
<p
v-for="notice in quotedNotices"
:key="notice.key"
:class="['attachment-groups__notice', `attachment-groups__notice--${notice.tone}`]"
role="status"
data-quoted-link
>
<AppIcon
:name="notice.tone === 'warning' ? 'alert-circle' : 'info'"
:size="15"
class="attachment-groups__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
</div>
</template>
<style scoped>
.attachment-groups {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 묶음 머리 — 12.5 굵게 + 12 회색 (디자인 원본 ComposeLargeFiles) */
.attachment-groups__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
margin-top: var(--space-2);
}
.attachment-groups__title {
margin: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-strong);
}
.attachment-groups__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.attachment-groups__hint--numeric {
font-variant-numeric: tabular-nums;
}
/* 보관 사용량이 많을 때 — 갈색 굵게, 머리 오른쪽 */
.attachment-groups__quota {
margin-left: auto;
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-warning-text);
font-variant-numeric: tabular-nums;
}
/* 링크 파일 — 일반 첨부와 같은 격자 (두 칸) */
.attachment-groups__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(17.5rem, 100%), 1fr));
gap: var(--space-6);
margin: 0;
padding: 0;
list-style: none;
}
/* 칸 아래 ⓘ 한 줄 — 12.5 회색, 끝난 인용 링크는 갈색 */
.attachment-groups__notice {
display: flex;
align-items: flex-start;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.attachment-groups__notice-icon {
margin-top: var(--space-2);
}
.attachment-groups__notice--warning {
color: var(--color-warning-text);
}
</style>
@@ -1,21 +1,30 @@
<script setup lang="ts">
// 쓰기 화면의 첨부 목록 — 파일마다 진행률, 실패한 파일은 사유와 함께 빨갛게 (디자인 원본 States "첨부 업로드")
// 어느 첨부든 할 수 있는 일은 "빼기"(✕) 하나다 — 올린 파일은 빼고 다시 고르면 되고, 전달 원본은 다시 올릴 수 없다.
// 11단계: 이 쓰기 화면에서 고른 파일(올리는 중 · 올림)에는 「링크로 보내기」 단추(40 — 마우스를 올리면 같은 글)가 더 있다 —
// 대용량 첨부(링크)로 옮긴다. 전달 원본 · 저장본에서 이어 온 첨부에는 없다 (디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import { LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { formatFileSize } from '@/shared/utils/format'
interface Props {
items: UploadItem[]
/** 「링크로 보내기」 단추를 보일 첨부인가 — 주지 않으면 없다 */
linkable?: (item: UploadItem) => boolean
}
interface Emits {
(e: 'remove', key: number): void
/** 「링크로 보내기」 — 그 첨부를 대용량 첨부로 옮긴다 */
(e: 'send-as-link', key: number): void
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
linkable: () => false,
})
const emit = defineEmits<Emits>()
const listRef = ref<HTMLUListElement | null>(null)
@@ -51,6 +60,20 @@ defineExpose({ focusRemove })
<template v-if="item.status === 'uploading'">올리는 중 {{ item.progress }}%</template>
<template v-else>{{ formatFileSize(item.size) }}</template>
</span>
<button
v-if="linkable(item)"
type="button"
class="upload-item__link"
:aria-label="`${item.name} ${LARGE_FILE_TEXT.sendAsLink}`"
:title="LARGE_FILE_TEXT.sendAsLink"
data-send-as-link
@click="emit('send-as-link', item.key)"
>
<AppIcon
name="link"
:size="15"
/>
</button>
<button
type="button"
class="upload-item__remove"
@@ -127,6 +150,7 @@ defineExpose({ focusRemove })
font-variant-numeric: tabular-nums;
}
.upload-item__link,
.upload-item__remove {
flex: 0 0 auto;
display: inline-flex;
@@ -140,6 +164,7 @@ defineExpose({ focusRemove })
color: var(--color-text-secondary);
}
.upload-item__link:hover,
.upload-item__remove:hover {
background: var(--color-surface-muted);
}
@@ -65,3 +65,85 @@ describe('ComposeFooter — [보내기 ▾] 메뉴 자리', () => {
expect(menu.classes()).not.toContain('compose__send-menu--up')
})
})
// 11단계 — 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled (초점은 받는다). 첨부 때문에 막히면 까닭 글을 두고,
// 누르면 보내지 않고 까닭을 읽어 준다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
describe('ComposeFooter — 첨부 때문에 막힌 [보내기]', () => {
const REASON = '올리는 중인 파일이 있습니다 — 다 올리면 보낼 수 있습니다.'
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
function mountFooter(props: Partial<typeof PROPS> & { blockedReason?: string | null } = {}) {
wrapper = mount(ComposeFooter, { props: { ...PROPS, ...props }, attachTo: document.body })
return wrapper
}
/** 누름 — 양식 제출을 막았는지 본다 */
function click(element: Element): MouseEvent {
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
element.dispatchEvent(event)
return event
}
it('두 단추는 aria-disabled(초점은 받는다)이고 까닭 글을 가리킨다 · 임시 저장은 그대로 된다', () => {
const view = mountFooter({ blockedReason: REASON })
for (const selector of ['.compose__send', '.compose__send-more']) {
const button = view.get(selector)
expect(button.attributes('disabled')).toBeUndefined()
expect(button.attributes('aria-disabled')).toBe('true')
expect(button.attributes('aria-describedby')).toBe('compose-send-why')
}
expect(view.get('#compose-send-why').text()).toBe(REASON)
expect(view.get('#compose-save-draft').attributes('disabled')).toBeUndefined()
})
it('[보내기]를 누르면 제출하지 않고 까닭을 읽어 준다 · ▾ 는 메뉴를 열지 않는다', async () => {
const view = mountFooter({ blockedReason: REASON })
expect(click(view.get('.compose__send').element).defaultPrevented).toBe(true)
await flushPromises()
expect(view.get('[data-send-blocked-status]').text()).toBe(REASON)
await view.get('.compose__send-more').trigger('click')
await flushPromises()
expect(view.find('[role="menu"]').exists()).toBe(false)
})
it('막는 까닭이 없으면 그대로 제출된다 — 까닭 글도 없다', () => {
const view = mountFooter()
const send = view.get('.compose__send')
expect(send.attributes('aria-disabled')).toBeUndefined()
expect(send.attributes('aria-describedby')).toBeUndefined()
expect(view.find('#compose-send-why').exists()).toBe(false)
expect(click(send.element).defaultPrevented).toBe(false)
})
it('보내는 중(canSend 거짓)도 aria-disabled 로 막지만 읽어 줄 까닭은 없다', async () => {
const view = mountFooter({ canSend: false, sending: true })
const send = view.get('.compose__send')
expect(send.attributes('aria-disabled')).toBe('true')
expect(send.attributes('aria-busy')).toBe('true')
expect(click(send.element).defaultPrevented).toBe(true)
await flushPromises()
expect(view.get('[data-send-blocked-status]').text()).toBe('')
})
it('메뉴가 열린 채 막히면 메뉴를 닫는다', async () => {
const view = mountFooter()
await view.get('.compose__send-more').trigger('click')
await flushPromises()
expect(view.find('[role="menu"]').exists()).toBe(true)
await view.setProps({ blockedReason: REASON })
await flushPromises()
expect(view.find('[role="menu"]').exists()).toBe(false)
})
})
+96 -22
View File
@@ -3,12 +3,15 @@
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
// Ctrl+Enter 는 그대로 [보내기]다. 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 첨부를 올리는 중 등 — canSend)
// 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 편집기를 불러오는 중 등 — canSend · 첨부의 까닭 — blockedReason)
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · ▾ .compose__send-more · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
import { computed, ref, watch } from 'vue'
// 상태 #compose-draft-status(ComposeDraftStatus) · 까닭 #compose-send-why · 줄 .compose__footer
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
@@ -19,7 +22,10 @@ import { usePopupPlacement } from '@/composables/usePopupPlacement'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
interface Props {
/** 지금 보낼 수 있는가 — 편집기 준비 · 보내는 중 아님 등 (첨부 때문에 막힌 것은 blockedReason) */
canSend: boolean
/** 첨부 때문에 보내기를 막는 까닭 글 — 없으면 null */
blockedReason?: string | null
sending: boolean
canSaveDraft: boolean
draftStatus: DraftSaveStatus
@@ -36,9 +42,14 @@ interface Emits {
(e: 'schedule'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
blockedReason: null,
})
const emit = defineEmits<Emits>()
/** 까닭 글의 id — 두 단추가 가리킨다 */
const REASON_ID = 'compose-send-why'
const moreRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const menuOpen = ref<boolean>(false)
@@ -56,9 +67,28 @@ const { upward } = usePopupPlacement({
const items: readonly EditorMenuItem[] = [{ id: 'schedule', label: SEND_LATER_TEXT.scheduleItem, icon: 'clock' }]
const trigger = computed<HTMLElement | null>(() => moreRef.value)
/** 두 단추를 누를 수 없다 — 지금 보낼 수 없거나 첨부 때문에 막혔다 */
const unavailable = computed(() => !props.canSend || props.blockedReason !== null)
/** 누른 뒤 읽어 줄 까닭 (role=status) */
const announcement = ref<string>('')
/** 막힌 단추를 눌렀다 — 보내지 않고 까닭을 읽어 준다 (같은 글이어도 다시 읽게 비웠다가 넣는다) */
async function announceBlocked() {
if (!props.blockedReason) return
announcement.value = ''
await nextTick()
announcement.value = props.blockedReason
}
/** [보내기] — 막혔으면 양식을 제출하지 않는다 */
function onSendClick(event: MouseEvent) {
if (!unavailable.value) return
event.preventDefault()
void announceBlocked()
}
function openMenu(keyboard: boolean) {
if (!props.canSend) return
if (unavailable.value) return
byKeyboard.value = keyboard
menuOpen.value = true
}
@@ -68,6 +98,10 @@ function onMoreClick(event: MouseEvent) {
menuOpen.value = false
return
}
if (unavailable.value) {
void announceBlocked()
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
@@ -86,16 +120,13 @@ function onMenuClose(restoreFocus: boolean) {
function onSelect(id: string) {
menuOpen.value = false
moreRef.value?.focus()
if (id === 'schedule' && props.canSend) emit('schedule')
if (id === 'schedule' && !unavailable.value) emit('schedule')
}
// 보내는 중 등으로 꺼지면 메뉴도 닫는다
watch(
() => props.canSend,
(can) => {
if (!can) menuOpen.value = false
},
)
// 보내는 중 · 첨부를 올리기 시작함 등으로 꺼지면 메뉴도 닫는다
watch(unavailable, (off) => {
if (off) menuOpen.value = false
})
</script>
<template>
@@ -104,8 +135,10 @@ watch(
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-describedby="blockedReason ? REASON_ID : undefined"
:aria-busy="sending"
@click="onSendClick"
>
<AppIcon
name="send"
@@ -126,7 +159,8 @@ watch(
:title="SEND_LATER_TEXT.menuButton"
aria-haspopup="menu"
:aria-expanded="menuOpen"
:disabled="!canSend"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-describedby="blockedReason ? REASON_ID : undefined"
@click="onMoreClick"
@keydown="onMoreKeydown"
>
@@ -157,6 +191,22 @@ watch(
>
임시 저장
</button>
<p
v-if="blockedReason"
:id="REASON_ID"
class="compose__send-why"
>
<AppIcon
name="info"
:size="15"
/>
<span>{{ blockedReason }}</span>
</p>
<span
class="visually-hidden"
role="status"
data-send-blocked-status
>{{ announcement }}</span>
<ComposeDraftStatus
:status="draftStatus"
:saved-at="draftSavedAt"
@@ -211,8 +261,9 @@ watch(
box-shadow: inset 0 0 0 var(--border-width) var(--color-on-brand-divider);
}
.compose__send:disabled + .compose__send-divider {
opacity: 0.6;
/* 막힌 동안은 두 단추와 사이 선을 회색으로 (디자인 원본 ComposeLargeFiles: 바탕 #D0D5DD) */
.compose__send[aria-disabled='true'] + .compose__send-divider {
background: var(--color-border-strong);
}
/* ▾ — 44×44 (보드의 40 은 새 밀도 예외가 되므로 44 — README "9단계") */
@@ -233,17 +284,30 @@ watch(
background: var(--color-brand-strong);
}
.compose__send:hover:not(:disabled),
.compose__send-more:hover:not(:disabled) {
.compose__send:hover:not([aria-disabled='true']),
.compose__send-more:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
.compose__send:disabled,
.compose__send-more:disabled {
opacity: 0.6;
/* 꺼짐 — disabled 가 아니라 aria-disabled 라 초점은 받는다. 마우스를 올려도 그대로 */
.compose__send[aria-disabled='true'],
.compose__send-more[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
/* 보내는 중 — 브랜드색 그대로 흐리게 ("보내는 중…") */
.compose__send[aria-busy='true'] {
background: var(--color-brand);
opacity: 0.6;
}
.compose__send[aria-busy='true'] + .compose__send-divider,
.compose__send[aria-busy='true'] ~ .compose__send-more {
background: var(--color-brand);
opacity: 0.6;
}
/* 메뉴 폭 260 (디자인 원본 MailSendLater) — 단추 덩어리 왼쪽에 맞춘다 */
.compose__send-menu {
min-width: 16.25rem;
@@ -278,6 +342,16 @@ watch(
cursor: not-allowed;
}
/* 보내기를 막는 까닭 — ⓘ 12.5 (디자인 원본 ComposeLargeFiles) */
.compose__send-why {
display: flex;
align-items: center;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.compose__sender {
margin-left: auto;
font-size: var(--font-size-small);
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import type { LargeUploadItem } from '@/composables/useLargeFileUploads'
import { createTestRouter } from '@/test-utils/router'
import ComposeLargeFileItem from './ComposeLargeFileItem.vue'
// 쓰기 화면의 링크 파일 한 줄 — 올리는 줄 오른쪽 글 (리뷰 L7 · L4 · L8)
// 조각을 다 보낸 뒤 끝내기를 기다리는 동안은 100% 에 "시간 계산 중"이 아니라 "마무리하는 중", 차례를 기다리는 파일은 "차례를 기다리는 중"
const MB = 1024 * 1024
function item(changes: Partial<LargeUploadItem>): LargeUploadItem {
return {
key: 1,
uploadId: 'u1',
name: '설계도_v3.dwg',
size: 64 * MB,
phase: 'uploading',
receivedBytes: 16 * MB,
sendingBytes: 0,
finishing: false,
waitingTurn: false,
scanStatus: null,
keptUntil: null,
missingReason: null,
errorText: null,
errorLinksSettings: false,
repickMismatch: false,
restarted: false,
etaMs: null,
...changes,
}
}
function render(changes: Partial<LargeUploadItem>) {
return mount(ComposeLargeFileItem, {
props: { item: item(changes) },
global: { plugins: [createTestRouter()] },
})
}
describe('ComposeLargeFileItem — 올리는 줄 오른쪽 글', () => {
it('올리는 중 — 처음에는 "시간 계산 중", 속도를 알면 남은 시간', () => {
expect(render({}).get('.large-upload__aside').text()).toBe('시간 계산 중')
expect(render({ etaMs: 3 * 60_000 }).get('.large-upload__aside').text()).toBe('약 3분 남음')
})
it('조각을 다 보냈으면 끝내기를 기다리는 동안(진행 중 409 의 기다림 포함) "마무리하는 중"', () => {
const view = render({ receivedBytes: 64 * MB, etaMs: null })
expect(view.get('.large-upload__aside').text()).toBe('마무리하는 중')
expect(view.get('.large-upload__line-text').text()).toBe('올리는 중 · 64 MB / 64 MB')
// 진행 알림(10% 마다)도 멈춘다
expect(view.find('[role="status"]').exists()).toBe(false)
})
it('만들었고 차례를 기다리는 파일 — "차례를 기다리는 중"', () => {
const view = render({ receivedBytes: 0, waitingTurn: true })
expect(view.get('.large-upload__aside').text()).toBe('차례를 기다리는 중')
})
it('[멈춤] 단추 이름은 "{파일} 올리기 멈춤"', () => {
expect(render({}).get('[data-large-pause]').attributes('aria-label')).toBe('설계도_v3.dwg 올리기 멈춤')
})
})
@@ -0,0 +1,519 @@
<script setup lang="ts">
// 쓰기 화면의 링크 파일 한 줄 (디자인 원본 ComposeLargeFiles · README "11단계 › 쓰기 화면 — 첨부 칸")
// 두 줄 — 아이콘(링크 · 청록) · 이름 · 단추(40) / 크기 · 상태(12px, 아이콘 선만큼 26px 들여). 올리는 동안 3px 막대. 뱃지 · 알약 없이 글자만.
// 상태: 올리는 중(+ 남은 시간 · 처음 몇 초는 "시간 계산 중" · 차례를 기다리는 중 · 조각을 다 보낸 뒤 "마무리하는 중") ·
// 다시 잇는 중(갈색) · 멈춤(회색 막대) · 오래 끊김(빨간 줄 + [다시 시도]) ·
// 다 올림(검사 중 · 검사하지 못함 · 깨끗하면 크기만) · 위험(빨간 줄 · 경고 삼각형) · 다시 열어 멈춘 파일(노란 줄 + [파일 고르기]) · 없는 파일 · 올리기 전 거절.
// ✕ 는 「빼기」 — 올리던 것을 멈추고 서버의 조각도 지운다. 화면 낭독기에는 진행을 10% 마다 알린다(보이지 않는 status).
// [파일 고르기]는 보이지 않는 파일 입력을 연다 — 고른 파일은 쓰기 화면이 같은 파일인지 맞춘다(repick)
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import type { LargeUploadItem } from '@/composables/useLargeFileUploads'
import { COMPOSE_ATTACHMENT_ACCEPT, LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { formatFileSize } from '@/shared/utils/format'
import { formatKstMonthDay } from '@/shared/utils/kst-date'
import { formatEta, formatUploadProgress, progressPercent } from '@/shared/utils/large-file'
interface Props {
item: LargeUploadItem
}
interface Emits {
(e: 'pause', key: number): void
(e: 'resume', key: number): void
(e: 'remove', key: number): void
(e: 'repick', key: number, file: File): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 줄 모양 — 보통 · 노랑(다시 골라야 이어진다) · 빨강(빼야 한다 · 오래 끊김 · 거절) */
type RowTone = 'normal' | 'warning' | 'danger'
/** 막대 색 — 청록(올리는 중) · 회색(멈춤) · 빨강(오래 끊김) · 갈색(다시 골라야 한다) */
type BarTone = 'brand' | 'muted' | 'danger' | 'warning'
interface ItemView {
tone: RowTone
/** 둘째 줄 글 */
line: string
/** 둘째 줄의 글 색 — 회색 · 갈색(다시 잇는 중 · 다시 고르기) · 빨강(role=alert) */
lineTone: 'muted' | 'warning' | 'danger'
/** 둘째 줄 오른쪽 — 남은 시간 · 마무리하는 중 */
aside: string | null
bar: BarTone | null
/** 첫 줄 오른쪽의 크기 — 거절된 줄만 (이름 옆) */
sizeBeside: boolean
}
const uploaded = computed(() => props.item.receivedBytes + props.item.sendingBytes)
const progressText = computed(() => formatUploadProgress(uploaded.value, props.item.size))
const percent = computed(() => progressPercent(uploaded.value, props.item.size))
/** 화면 낭독기에 알릴 진행 — 10% 단위로만 바뀐다 */
const announcedPercent = computed(() => Math.floor(percent.value / 10) * 10)
/** 조각은 다 갔고 서버가 파일로 만드는 중 — 끝내기를 기다리는 동안(진행 중 409 의 기다림 포함) 100% 에 "시간 계산 중"이 아니다 */
const finishingNow = computed(
() => props.item.finishing || (props.item.uploadId !== null && props.item.size > 0 && props.item.receivedBytes >= props.item.size),
)
/** 올리는 줄 오른쪽 — 마무리하는 중 · 차례를 기다리는 중 · 남은 시간(처음 몇 초는 "시간 계산 중") */
const uploadingAside = computed(() => {
if (finishingNow.value) return LARGE_FILE_TEXT.finishing
if (props.item.waitingTurn) return LARGE_FILE_TEXT.queued
return props.item.etaMs === null ? LARGE_FILE_TEXT.etaCalculating : formatEta(props.item.etaMs)
})
const view = computed<ItemView>(() => {
const item = props.item
const size = formatFileSize(item.size)
switch (item.phase) {
case 'uploading':
return {
tone: 'normal',
line: (item.restarted ? LARGE_FILE_TEXT.restarting : LARGE_FILE_TEXT.uploading)(progressText.value),
lineTone: 'muted',
aside: uploadingAside.value,
bar: 'brand',
sizeBeside: false,
}
case 'reconnecting':
return {
tone: 'normal',
line: LARGE_FILE_TEXT.reconnecting(progressText.value),
lineTone: 'warning',
aside: null,
bar: 'brand',
sizeBeside: false,
}
case 'paused':
return {
tone: 'normal',
line: LARGE_FILE_TEXT.paused(progressText.value),
lineTone: 'muted',
aside: null,
bar: 'muted',
sizeBeside: false,
}
case 'stalled':
return {
tone: 'danger',
line:
item.errorText ??
(item.receivedBytes > 0
? LARGE_FILE_TEXT.stalled(formatFileSize(item.receivedBytes))
: LARGE_FILE_TEXT.stalledEmpty),
lineTone: 'danger',
aside: null,
bar: 'danger',
sizeBeside: false,
}
case 'waiting-file':
return {
tone: 'warning',
line: LARGE_FILE_TEXT.waitingFile(progressText.value),
lineTone: 'warning',
aside: null,
bar: 'warning',
sizeBeside: false,
}
case 'ready': {
if (item.scanStatus === 'infected') {
return { tone: 'danger', line: LARGE_FILE_TEXT.infected, lineTone: 'danger', aside: null, bar: null, sizeBeside: false }
}
const line =
item.scanStatus === 'pending'
? LARGE_FILE_TEXT.scanning(size)
: item.scanStatus === 'unscannable'
? LARGE_FILE_TEXT.unscannable(size)
: item.keptUntil
? LARGE_FILE_TEXT.keptInDraft(size, formatKstMonthDay(item.keptUntil))
: size
return { tone: 'normal', line, lineTone: 'muted', aside: null, bar: null, sizeBeside: false }
}
case 'missing':
return {
tone: 'danger',
line:
item.missingReason === 'expired'
? LARGE_FILE_TEXT.missingExpired
: item.missingReason === 'deleted'
? LARGE_FILE_TEXT.missingDeleted
: LARGE_FILE_TEXT.missingUnknown,
lineTone: 'danger',
aside: null,
bar: null,
sizeBeside: false,
}
// 'rejected' — 올리기 전 검사 · 만들기에서 거절 (이름 옆에 크기)
default:
return {
tone: 'danger',
line: item.errorText ?? LARGE_FILE_TEXT.stalledEmpty,
lineTone: 'danger',
aside: null,
bar: null,
sizeBeside: true,
}
}
})
/** 올리는 동안만 진행을 알린다 (차례를 기다리거나 마무리하는 동안은 바뀌지 않는다) */
const announcing = computed(() => props.item.phase === 'uploading' && !finishingNow.value && !props.item.waitingTurn)
function pickFile() {
fileInputRef.value?.click()
}
function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 같은 파일을 다시 고를 수 있게 비운다
input.value = ''
if (file) emit('repick', props.item.key, file)
}
</script>
<template>
<li
:class="['large-upload', `large-upload--${view.tone}`]"
:data-large-upload="item.key"
:data-phase="item.phase"
>
<div class="large-upload__row">
<AppIcon
:name="view.tone === 'danger' && item.phase === 'ready' ? 'alert-triangle' : 'link'"
class="large-upload__icon"
/>
<span class="large-upload__name">{{ item.name }}</span>
<span
v-if="view.sizeBeside"
class="large-upload__size"
>{{ formatFileSize(item.size) }}</span>
<button
v-if="item.phase === 'uploading' || item.phase === 'reconnecting'"
type="button"
class="large-upload__icon-button"
:aria-label="LARGE_FILE_TEXT.pauseLabel(item.name)"
:title="LARGE_FILE_TEXT.pause"
data-large-pause
@click="emit('pause', item.key)"
>
<AppIcon
name="pause"
:size="14"
:stroke-width="2"
/>
</button>
<button
v-else-if="item.phase === 'paused'"
type="button"
class="large-upload__icon-button"
:aria-label="`${item.name} ${LARGE_FILE_TEXT.resume}`"
:title="LARGE_FILE_TEXT.resume"
data-large-resume
@click="emit('resume', item.key)"
>
<AppIcon
name="play"
:size="14"
:stroke-width="2"
/>
</button>
<button
v-else-if="item.phase === 'stalled'"
type="button"
class="large-upload__text-button large-upload__text-button--danger"
data-large-retry
@click="emit('resume', item.key)"
>
{{ LARGE_FILE_TEXT.retry }}
</button>
<template v-else-if="item.phase === 'waiting-file'">
<button
type="button"
class="large-upload__text-button large-upload__text-button--warning"
data-large-repick
@click="pickFile"
>
{{ LARGE_FILE_TEXT.repick }}
</button>
<input
ref="fileInputRef"
type="file"
class="visually-hidden"
tabindex="-1"
aria-hidden="true"
:accept="COMPOSE_ATTACHMENT_ACCEPT"
:aria-label="LARGE_FILE_TEXT.repickLabel(item.name)"
@change="onFileChosen"
>
</template>
<button
type="button"
class="large-upload__icon-button"
:aria-label="`${item.name} ${LARGE_FILE_TEXT.remove}`"
:title="LARGE_FILE_TEXT.remove"
data-large-remove
@click="emit('remove', item.key)"
>
<AppIcon
name="x"
:size="14"
:stroke-width="2"
/>
</button>
</div>
<p
:class="['large-upload__line', `large-upload__line--${view.lineTone}`]"
:role="view.lineTone === 'danger' ? 'alert' : undefined"
>
<span class="large-upload__line-text">
{{ view.line }}
<RouterLink
v-if="item.phase === 'rejected' && item.errorLinksSettings"
class="large-upload__link"
:to="{ name: SETTINGS_ROUTES.largeFiles }"
>{{ LARGE_FILE_TEXT.quotaLink }}</RouterLink>
</span>
<span
v-if="view.aside"
class="large-upload__aside"
>{{ view.aside }}</span>
</p>
<progress
v-if="view.bar"
:class="['large-upload__progress', `large-upload__progress--${view.bar}`]"
max="100"
:value="percent"
aria-hidden="true"
/>
<p
v-if="item.phase === 'waiting-file' && item.repickMismatch"
class="large-upload__line large-upload__line--danger large-upload__mismatch"
role="alert"
>
{{ LARGE_FILE_TEXT.repickMismatch }}
</p>
<span
v-if="announcing"
class="visually-hidden"
role="status"
>{{ LARGE_FILE_TEXT.progressAnnounce(item.name, String(announcedPercent)) }}</span>
</li>
</template>
<style scoped>
.large-upload {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) var(--space-2) var(--space-8) var(--space-10);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
.large-upload__row {
display: flex;
align-items: center;
gap: var(--space-10);
}
/* 링크 아이콘 — 청록 (일반 첨부의 파일 아이콘과 가른다) */
.large-upload__icon {
color: var(--color-brand-strong);
}
.large-upload__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.large-upload__size {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-variant-numeric: tabular-nums;
}
/* 40×40 — 일반 첨부의 ✕ 와 같은 크기 */
.large-upload__icon-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-40);
height: var(--space-40);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.large-upload__icon-button:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* [다시 시도] · [파일 고르기] — 높이 40, 흰 바탕 테두리 단추 */
.large-upload__text-button {
flex: 0 0 auto;
min-height: var(--space-40);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
font-weight: 600;
}
.large-upload__text-button--danger {
border-color: var(--color-danger-border);
color: var(--color-danger);
}
.large-upload__text-button--danger:hover {
background: var(--color-danger-surface);
}
.large-upload__text-button--warning {
border-color: var(--color-warning-border);
color: var(--color-warning-text);
}
.large-upload__text-button--warning:hover {
background: var(--color-warning-hover);
}
/* 둘째 줄 — 아이콘 선만큼 26px 들여 (아이콘 16 + 사이 10) */
.large-upload__line {
display: flex;
gap: var(--space-8);
margin: 0;
padding: 0 var(--space-10) 0 calc(var(--space-16) + var(--space-10));
font-size: var(--font-size-small);
font-variant-numeric: tabular-nums;
}
.large-upload__line-text {
flex: 1 1 auto;
min-width: 0;
}
.large-upload__aside {
flex: 0 0 auto;
}
.large-upload__line--muted {
color: var(--color-text-muted);
}
.large-upload__line--warning {
color: var(--color-warning-text);
}
/* 빨간 글 — 12.5 (일반 첨부의 실패 글과 같다) */
.large-upload__line--danger {
font-size: var(--font-size-label);
color: var(--color-danger);
}
.large-upload__mismatch {
margin-top: var(--space-4);
}
.large-upload__link {
margin-left: var(--space-4);
color: inherit;
font-weight: 600;
}
/* 3px 막대 — 인라인 스타일 대신 <progress> 를 꾸민다 (일반 첨부의 진행률과 같은 방법) */
.large-upload__progress {
appearance: none;
width: calc(100% - var(--space-16) - var(--space-10) - var(--space-10));
height: var(--space-3);
margin: var(--space-4) var(--space-10) 0 calc(var(--space-16) + var(--space-10));
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
overflow: hidden;
}
.large-upload__progress::-webkit-progress-bar {
border-radius: var(--space-2);
background: var(--color-skeleton);
}
.large-upload__progress::-webkit-progress-value {
border-radius: var(--space-2);
background: var(--color-brand);
}
.large-upload__progress::-moz-progress-bar {
border-radius: var(--space-2);
background: var(--color-brand);
}
/* 멈춤 — 회색 막대 */
.large-upload__progress--muted::-webkit-progress-value {
background: var(--color-icon-muted);
}
.large-upload__progress--muted::-moz-progress-bar {
background: var(--color-icon-muted);
}
/* 오래 끊김 — 빨간 막대 · 옅은 빨강 바탕 */
.large-upload__progress--danger,
.large-upload__progress--danger::-webkit-progress-bar {
background: var(--color-danger-border);
}
.large-upload__progress--danger::-webkit-progress-value {
background: var(--color-danger);
}
.large-upload__progress--danger::-moz-progress-bar {
background: var(--color-danger);
}
/* 다시 골라야 이어지는 파일 — 갈색 막대(#B7791F — 같은 값의 토큰 --color-star 를 다시 쓴다) · 노란 테두리 바탕 */
.large-upload__progress--warning,
.large-upload__progress--warning::-webkit-progress-bar {
background: var(--color-warning-border);
}
.large-upload__progress--warning::-webkit-progress-value {
background: var(--color-star);
}
.large-upload__progress--warning::-moz-progress-bar {
background: var(--color-star);
}
/* 노란 줄 — 저장본에서 이어 온 올리다 멈춘 파일 */
.large-upload--warning {
border-color: var(--color-warning-border);
background: var(--color-warning-surface);
}
/* 빨간 줄 — 빼야 하는 파일 · 오래 끊김 · 올리기 전 거절 */
.large-upload--danger {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.large-upload--danger .large-upload__name,
.large-upload--danger .large-upload__size,
.large-upload--danger .large-upload__icon {
color: var(--color-danger);
}
</style>
@@ -2,13 +2,17 @@
// 쓰기 화면 나가기 전 확인 — 디자인 README "2단계 › 임시 저장 › 나가기 확인" (공통 틀 LeaveConfirmDialog · ModalDialog)
// 떠나기 전에 남은 변경을 저장하고, 저장하지 못한 내용이 있을 때만 쓰기 화면이 연다.
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
// 대용량 파일을 올리는 중이면 글이 바뀐다 — "올리는 중인 파일이 있습니다" / 나가면 올리기가 멈추고, 저장본을 다시 열어 같은 파일을 고르면 잇는다 (11단계)
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import { COMPOSE_LEAVE_UPLOADING_DIALOG } from '@/shared/constants/large-file'
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
open: boolean
/** 대용량 파일을 올리는 중이다 — 올리기가 멈춘다는 글로 묻는다 */
uploading?: boolean
}
interface Emits {
@@ -18,7 +22,9 @@ interface Emits {
(e: 'leave'): void
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
uploading: false,
})
const emit = defineEmits<Emits>()
</script>
@@ -26,7 +32,7 @@ const emit = defineEmits<Emits>()
<LeaveConfirmDialog
:open="open"
id-prefix="compose-leave"
:copy="COMPOSE_LEAVE_DIALOG"
:copy="uploading ? COMPOSE_LEAVE_UPLOADING_DIALOG : COMPOSE_LEAVE_DIALOG"
@stay="emit('stay')"
@leave="emit('leave')"
/>
@@ -0,0 +1,67 @@
<script setup lang="ts">
// 대용량 첨부 링크 끊기 확인 — alertdialog 400 (디자인 원본 MailReadLargeFiles · SettingsLargeFiles)
// "대용량 첨부 링크를 끊을까요?" + 글(읽기 화면은 파일 이름, 설정은 메일 제목) · [링크 끊기](빨강 채움) [취소]
// 되돌릴 수 없는 일이라 처음 초점은 「취소」, Esc 도 취소다. 끊는 동안은 [링크 끊기]를 aria-disabled 로 막는다(초점을 지킨다).
// 쓰는 곳: 읽기 화면의 보낸 사람 자신 줄(MessageLargeFileLinkBar) · 설정 › 대용량 첨부(LargeFileLinkList) — 둘이 함께 보일 수 없지만 id 앞부분을 따로 받는다
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { LARGE_FILE_REVOKE_TEXT } from '@/shared/constants/large-file'
interface Props {
open: boolean
/** 창 글 — 읽기 화면은 파일 이름을, 설정은 메일 제목을 넣어 만든다 */
message: string
/** 끊는 중 — [링크 끊기]를 막는다 */
busy?: boolean
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
function onConfirm() {
if (!props.busy) emit('confirm')
}
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
medium
:title="LARGE_FILE_REVOKE_TEXT.title"
:message="message"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@cancel="emit('cancel')"
>
<template #actions>
<button
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
data-revoke-confirm
@click="onConfirm"
>
{{ LARGE_FILE_REVOKE_TEXT.confirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
data-revoke-cancel
@click="emit('cancel')"
>
{{ LARGE_FILE_REVOKE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
+14 -2
View File
@@ -13,6 +13,7 @@
// 외부 이미지를 보이는 본문(imagesShown — 서버가 중계 주소로 바꿔 넣었다, 9단계)일 때만 CSP 의 그림에 우리 출처의 중계 경로
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
// 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { buildMailDocument } from '@/shared/utils/mail-html'
@@ -26,6 +27,8 @@ interface Props {
imagesShown?: boolean
/** iframe 이름 (스크린리더) */
title?: string
/** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */
foldLargeFiles?: boolean
}
interface Emits {
@@ -43,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
textBody: null,
imagesShown: false,
title: '메일 본문',
foldLargeFiles: false,
})
const emit = defineEmits<Emits>()
@@ -54,7 +58,12 @@ const frameRef = ref<HTMLIFrameElement | null>(null)
const contentHeight = ref<number | null>(null)
const srcdoc = computed(() =>
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody, imagesShown: props.imagesShown }),
buildMailDocument({
htmlBody: props.htmlBody,
textBody: props.textBody,
imagesShown: props.imagesShown,
foldLargeFiles: props.foldLargeFiles,
}),
)
let resizeObserver: ResizeObserver | null = null
@@ -88,7 +97,10 @@ function onFrameKeydown(event: KeyboardEvent) {
function onLoad() {
documentLoaded = true
// 문서를 새로 열 때마다 단다 — 옛 문서와 함께 옛 처리기도 사라진다
frameRef.value?.contentDocument?.addEventListener('keydown', onFrameKeydown)
const doc = frameRef.value?.contentDocument
doc?.addEventListener('keydown', onFrameKeydown)
// 접은 덩이를 열고 닫으면 높이가 바뀐다 — toggle 은 거품이 일지 않아 잡는 단계(capture)에서 받는다
doc?.addEventListener('toggle', scheduleMeasure, true)
measure()
}
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { CLOCK } from '@/composables/useClock'
import type { MessageLargeFile, SharedFileStatus, ShareLink } from '@/shared/types/large-file'
import type { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
import MessageAttachments from './MessageAttachments.vue'
// 읽기 화면의 대용량 첨부 (11단계 — 디자인 원본 MailReadLargeFiles · 계약 4절 "우리 웹메일에서 읽기")
// 첨부 칸에 첨부처럼 · 끝나는 날 · 상태 글 · 보낸 사람 자신의 받은 수와 [링크 복사] · [링크 끊기](확인 창 — 처음 초점 「취소」)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const GB = 1024 * 1024 * 1024
const MB = 1024 * 1024
const URL = 'https://files.arasystem.it.kr/s/Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
function largeFile(changes: Partial<MessageLargeFile>): MessageLargeFile {
return {
linkId: 'link1',
fileId: 'f1',
name: '설계도_v3.dwg',
size: 1.2 * GB,
status: 'available',
expiresAt: '2026-11-29T14:59:59.999Z',
revokedAt: null,
url: URL,
downloads: null,
...changes,
}
}
const DESIGN = largeFile({})
const VIDEO = largeFile({ fileId: 'f2', name: '현장영상_1029.mp4', size: 612 * MB })
describe('MessageAttachments — 대용량 첨부', () => {
let pinia: Pinia
let wrapper: VueWrapper | null = null
let downloads: string[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function mountWith(largeFiles: MessageLargeFile[], handler: MockHandler = () => ok(USER)) {
const requests = installMockApi(handler)
wrapper = mount(MessageAttachments, {
props: { messageId: 'm1', attachments: [], largeFiles },
global: { plugins: [pinia] },
attachTo: document.body,
})
return { wrapper, requests }
}
it('사내 받는 사람 — 첨부처럼 "크기 · 끝나는 날", 「첨부 파일 N개」는 둘을 합한 수 · 받기는 세션을 확인한 뒤 그 메일의 경로로', async () => {
const { wrapper: view, requests } = mountWith([DESIGN, VIDEO])
expect(view.get('#reader-attachments-title').text()).toBe('첨부 파일 2개')
expect(view.findAll('.large-item__info').map((info) => info.text())).toEqual([
'1.2 GB · 11월 29일까지',
'612 MB · 11월 29일까지',
])
// 받는 사람에게는 링크 줄이 없다
expect(view.find('[data-large-link]').exists()).toBe(false)
const link = view.get<HTMLAnchorElement>('[data-large-file="f1"] a.large-item__download')
expect(link.attributes('href')).toBe('/api/messages/m1/large-files/f1')
await link.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
expect(downloads).toEqual(['/api/messages/m1/large-files/f1'])
})
it('검사 중이면 받기 단추를 aria-disabled 로 끄고, 누르면 까닭을 읽어 준다 · 위험 · 끝난 링크는 단추가 없다', async () => {
const { wrapper: view } = mountWith([
largeFile({ status: 'scanning' }),
largeFile({ fileId: 'f2', status: 'infected', name: '견적_요청서.zip' }),
largeFile({ fileId: 'f3', status: 'revoked' }),
])
const blocked = view.get('[data-large-file="f1"] .large-item__download')
expect(blocked.element.tagName).toBe('BUTTON')
expect(blocked.attributes('aria-disabled')).toBe('true')
await blocked.trigger('click')
await flushPromises()
expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다')
expect(downloads).toEqual([])
expect(view.get('[data-large-file="f2"]').classes()).toContain('large-item--danger')
expect(view.find('[data-large-file="f2"] .large-item__download').exists()).toBe(false)
expect(view.get('[data-large-file="f3"]').classes()).toContain('large-item--dim')
expect(view.find('[data-large-file="f3"] .large-item__download').exists()).toBe(false)
})
it('보낸 사람 자신 — 받은 수와 링크 줄. [링크 복사]는 덩이의 주소를 복사한다', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const own = { count: 3, lastDownloadedAt: '2026-10-30T05:41:00Z' }
const { wrapper: view } = mountWith([
largeFile({ downloads: own }),
largeFile({ fileId: 'f2', name: '현장영상_1029.mp4', size: 612 * MB, downloads: { count: 0, lastDownloadedAt: null } }),
])
expect(view.findAll('.large-item__info').map((info) => info.text())).toEqual([
'1.2 GB · 3회 받음 · 11월 29일까지',
'612 MB · 아직 받지 않음 · 11월 29일까지',
])
expect(view.get('.link-bar__note').text()).toBe(
'링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.',
)
await view.get('[data-link-copy]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(URL)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['링크를 복사했습니다.'])
})
it('[링크 끊기] — 확인 창(처음 초점 「취소」) 뒤 끊고, 첨부 칸은 "공유 멈춤" · 알림 하나(되돌리기 없음)', async () => {
const revoked: ShareLink = {
id: 'link1',
sentEmailId: 'm1',
subject: '11월 현장 점검 자료',
sentAt: '2026-10-30T05:15:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
status: 'revoked',
revokedAt: '2026-10-31T01:00:00Z',
files: [
{ id: 'f1', name: '설계도_v3.dwg', size: 1.2 * GB, status: 'revoked', downloadCount: 3, lastDownloadedAt: null },
],
}
const own = { count: 3, lastDownloadedAt: null }
const mailStore = useMailStore()
mailStore.selected = { id: 'm1', largeFiles: [largeFile({ downloads: own })] } as unknown as MessageDetail
const { wrapper: view, requests } = mountWith([largeFile({ downloads: own })], ({ method, url }) =>
method === 'POST' && url === '/share-links/link1/revocation' ? ok(revoked) : ok({}),
)
await view.get('[data-link-revoke]').trigger('click')
await flushPromises()
const dialog = view.get('dialog[role="alertdialog"]')
expect(dialog.text()).toContain('대용량 첨부 링크를 끊을까요?')
expect(dialog.text()).toContain('받는 사람 모두 이 메일의 파일 1개(설계도_v3.dwg)를 더는 내려받을 수 없습니다.')
expect(document.activeElement?.hasAttribute('data-revoke-cancel')).toBe(true)
await view.get('[data-revoke-confirm]').trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['POST /share-links/link1/revocation'])
expect(mailStore.selected?.largeFiles?.[0]?.status).toBe('revoked')
expect(mailStore.selected?.largeFiles?.[0]?.revokedAt).toBe('2026-10-31T01:00:00Z')
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
['링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.', null],
])
})
it('검사 중인 파일 — 막힌 [다운로드]를 누르면 한 번 바로 묻고, 15초 뒤 다시 묻는다 · 끝나면 받기 단추가 생기고 더 묻지 않는다 (리뷰 L5)', async () => {
const clock = createManualClock(Date.parse('2026-10-30T05:15:00Z'))
const mailStore = useMailStore()
mailStore.selected = { id: 'm1', largeFiles: [largeFile({ status: 'scanning' })] } as unknown as MessageDetail
let status: SharedFileStatus = 'scanning'
const requests = installMockApi(({ method, url }) =>
method === 'GET' && url === '/messages/m1'
? ok({ id: 'm1', largeFiles: [largeFile({ status })] })
: ok(USER),
)
// 읽기 화면처럼 열린 메일의 대용량 첨부를 그대로 넘긴다
const Reader = defineComponent({
setup() {
return () =>
h(MessageAttachments, { messageId: 'm1', attachments: [], largeFiles: mailStore.selected?.largeFiles ?? [] })
},
})
wrapper = mount(Reader, {
global: { plugins: [pinia], provide: { [CLOCK as symbol]: clock } },
attachTo: document.body,
})
const refetches = () => requests.filter((request) => request.method === 'GET' && request.url === '/messages/m1')
await wrapper.get('[data-large-file="f1"] .large-item__download').trigger('click')
await flushPromises()
expect(refetches()).toHaveLength(1)
expect(refetches()[0]?.config.silent).toBe(true)
status = 'available'
clock.advance(15_000)
await flushPromises()
expect(refetches()).toHaveLength(2)
expect(wrapper.get('[data-large-file="f1"] .large-item__download').element.tagName).toBe('A')
clock.advance(600_000)
await flushPromises()
expect(refetches()).toHaveLength(2)
})
})
@@ -3,19 +3,82 @@
// 주소는 axios baseURL 과 같은 규칙으로 만들어야 같은 출처·쿠키 경로로 요청이 간다 (useMail.attachmentDownloadUrl)
// 이동은 axios 의 401 갱신 규칙을 타지 않으므로, 누르면 바로 이동하지 않고 세션부터 확인한 뒤 받는다.
// href 는 그대로 둔다 — "링크 주소 복사" 같은 브라우저 기본 기능을 지키기 위해서다.
// 대용량 첨부(11단계 — MessageDetail.largeFiles)도 같은 칸에 첨부처럼 — 「첨부 파일 N개」는 둘을 합한 수 (MessageLargeFileItem).
// 보낸 사람 자신(링크 주인)에게는 칸 아래에 링크 줄 — 받은 수 · [링크 복사] · [링크 끊기] (MessageLargeFileLinkBar — 끝난 링크는 없다)
// 보안 검사 중인 대용량 첨부가 있는 동안은 상태를 가끔 다시 묻고, 막힌 [다운로드]를 누르면 한 번 바로 묻는다 (useLargeFileScanRefresh)
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MessageLargeFileItem from '@/components/mail/MessageLargeFileItem.vue'
import MessageLargeFileLinkBar from '@/components/mail/MessageLargeFileLinkBar.vue'
import { useClock } from '@/composables/useClock'
import { useLargeFileScanRefresh } from '@/composables/useLargeFileScanRefresh'
import { useMail } from '@/composables/useMail'
import type { MessageLargeFile } from '@/shared/types/large-file'
import type { Attachment } from '@/shared/types/mail'
import { formatFileSize } from '@/shared/utils/format'
import { isEndedLinkStatus } from '@/shared/utils/large-file-status'
import { useMailStore } from '@/stores/mail.store'
interface Props {
messageId: string
attachments: Attachment[]
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 온다 */
largeFiles?: MessageLargeFile[]
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
largeFiles: () => [],
})
const { attachmentDownloadUrl, downloadAttachment } = useMail()
const mailStore = useMailStore()
/** 검사 중인 파일이 있는 동안 열린 메일의 대용량 첨부를 다시 받는다 (메일을 바꾸거나 닫으면 · 세션이 바뀌면 멈춘다) */
const scanRefresh = useLargeFileScanRefresh({
messageId: () => props.messageId,
scanning: () => props.largeFiles.some((file) => file.status === 'scanning'),
refresh: () => mailStore.refreshOpenedLargeFiles(),
clock: useClock(),
})
const titleRef = ref<HTMLHeadingElement | null>(null)
/** 받을 수 없는 단추를 눌렀을 때 읽어 줄 까닭 (role=status) */
const announcement = ref<string>('')
const totalCount = computed(() => props.attachments.length + props.largeFiles.length)
/**
* 보낸 사람 자신의 살아 있는 링크 — 링크(linkId)마다 파일을 묶는다 (보통 한 통에 하나).
* 받은 수(downloads)가 오면 보는 사람이 링크 주인이다. 끝난 링크(끊음 · 기간 지남)는 줄이 없다
*/
const ownLinks = computed(() => {
const byLink = new Map<string, MessageLargeFile[]>()
for (const file of props.largeFiles) {
if (file.downloads === null) continue
byLink.set(file.linkId, [...(byLink.get(file.linkId) ?? []), file])
}
return [...byLink.values()].filter((files) => !files.some((file) => isEndedLinkStatus(file.status)))
})
/** 까닭을 다시 읽게 비웠다가 넣는다 */
async function announce(reason: string) {
announcement.value = ''
await nextTick()
announcement.value = reason
}
/** 받을 수 없는 대용량 첨부(검사 중)를 눌렀다 — 까닭을 읽어 주고, 끝났는지 한 번 바로 묻는다 */
function onLargeFileBlocked(reason: string) {
void announce(reason)
scanRefresh.refreshNow()
}
/** 링크를 끊어 줄이 사라졌다 — 초점을 칸 제목으로 (누른 단추가 없어졌다) */
async function onRevoked() {
await nextTick()
const active = document.activeElement
if (active === null || active === document.body) titleRef.value?.focus()
}
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
@@ -44,9 +107,11 @@ function onDownload(event: MouseEvent, attachmentId: string) {
>
<h3
id="reader-attachments-title"
ref="titleRef"
class="attachments__title"
tabindex="-1"
>
첨부 파일 {{ attachments.length }}개
첨부 파일 {{ totalCount }}개
</h3>
<ul class="attachments__list">
<li
@@ -75,7 +140,26 @@ function onDownload(event: MouseEvent, attachmentId: string) {
<AppIcon name="download" />
</a>
</li>
<MessageLargeFileItem
v-for="file in largeFiles"
:key="`${file.linkId}/${file.fileId}`"
:message-id="messageId"
:file="file"
@blocked="onLargeFileBlocked"
/>
</ul>
<MessageLargeFileLinkBar
v-for="files in ownLinks"
:key="files[0]?.linkId"
:files="files"
@revoked="onRevoked"
/>
<p
class="visually-hidden"
role="status"
>
{{ announcement }}
</p>
</section>
</template>
@@ -90,6 +174,7 @@ function onDownload(event: MouseEvent, attachmentId: string) {
.attachments__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
@@ -0,0 +1,198 @@
<script setup lang="ts">
// 읽기 화면의 대용량 첨부 한 줄 — 첨부 칸에 일반 첨부와 같은 모양으로 (디자인 원본 MailReadLargeFiles · 계약 4절 "우리 웹메일에서 읽기")
// 이름 아래 "1.2 GB · 11월 29일까지"(끝나는 날 — 한국 시간). 보낸 사람 자신에게는 받은 수를 더한다.
// [다운로드](44)는 내려받기 화면을 거치지 않고 바로 받는다 — 첨부처럼 <a href> 이동이고, 이동 전에 세션을 확인한다(confirmSession).
// 검사 중이면 [다운로드]를 aria-disabled 로 끈다(초점은 받는다 — 누르면 까닭을 읽어 준다, blocked). 위험 · 끝난 링크는 단추가 없다.
// 흐리게(기간 지남 · 공유 멈춤 · 횟수 초과 · 찾을 수 없음)는 투명도가 아니라 글자색이다 — 이름 #667085 · 아이콘 #98A2B3 (대비 4.5:1 이상)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { messageLargeFileView } from '@/shared/utils/large-file-status'
interface Props {
messageId: string
file: MessageLargeFile
}
interface Emits {
/** 검사 중이라 받을 수 없는 단추를 눌렀다 — 까닭을 화면 낭독기에 읽어 준다 */
(e: 'blocked', reason: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { messageLargeFileUrl, downloadMessageLargeFile } = useLargeFiles()
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const view = computed(() => messageLargeFileView(props.file))
const metaId = computed(() => `reader-large-file-${props.file.fileId}`)
const label = computed(() => MESSAGE_LARGE_FILE_TEXT.download(props.file.name))
/** 세션을 확인하는 중 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
let pending = false
function onDownload(event: MouseEvent) {
// 오른쪽 버튼(메뉴)은 브라우저에 맡긴다
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (pending) return
pending = true
void downloadMessageLargeFile(props.messageId, props.file.fileId).finally(() => {
pending = false
})
}
</script>
<template>
<li
:class="['large-item', `large-item--${view.tone}`]"
:data-large-file="file.fileId"
>
<span
class="large-item__icon"
aria-hidden="true"
>
<AppIcon :name="view.tone === 'danger' ? 'alert-triangle' : 'file'" />
</span>
<div class="large-item__meta">
<span class="large-item__name">{{ file.name }}</span>
<span
:id="metaId"
class="large-item__info"
>{{ view.meta }}</span>
</div>
<a
v-if="view.download === 'ready'"
class="large-item__download"
:href="messageLargeFileUrl(messageId, file.fileId)"
:aria-label="label"
download
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon name="download" />
</a>
<button
v-else-if="view.download === 'blocked'"
type="button"
class="large-item__download"
aria-disabled="true"
:aria-label="label"
:aria-describedby="metaId"
:title="view.meta"
@click="emit('blocked', view.meta)"
>
<AppIcon name="download" />
</button>
</li>
</template>
<style scoped>
.large-item {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-8);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
/* 단추가 없는 줄 — 오른쪽 여백을 단추 대신 둔다 */
.large-item--dim,
.large-item--danger {
padding-right: var(--space-12);
}
.large-item__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
.large-item__meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.large-item__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.large-item__info {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.large-item__download {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
}
.large-item__download:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 검사 중 — 초점은 받되 흐린 아이콘 (누르면 까닭을 읽어 준다) */
.large-item__download[aria-disabled='true'] {
color: var(--color-icon-muted);
cursor: not-allowed;
}
/* 끝난 링크 · 받을 수 없는 파일 — 흐린 글자색 (투명도가 아니다) */
.large-item--dim {
border-color: var(--color-border-subtle);
}
.large-item--dim .large-item__name {
color: var(--color-text-muted);
}
.large-item--dim .large-item__icon {
color: var(--color-icon-muted);
}
/* 위험 — 빨간 줄 */
.large-item--danger {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.large-item--danger .large-item__icon {
background: transparent;
color: var(--color-danger);
}
.large-item--danger .large-item__name,
.large-item--danger .large-item__info {
color: var(--color-danger);
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
// 보낸 사람 자신의 링크 줄 — 첨부 칸 아래 (디자인 원본 MailReadLargeFiles "보낸 사람 자신")
// "링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기]
// 단추는 36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외(--size-notice-action, 새 토큰 없음). 링크가 메일 한 통에 하나라 파일마다 두지 않는다.
// [링크 복사]는 이 메일 본문의 덩이 주소(MessageLargeFile.url) — 설정 목록에는 없다(서버는 링크를 해시로만 안다).
// [링크 끊기]는 확인 창(처음 초점 「취소」) 뒤 POST /share-links/{id}/revocation — 끊으면 알림 하나(되돌리기 없음), 첨부 칸은 "공유 멈춤"이 된다.
// 끊은 줄은 사라진다 — 초점은 쓰는 쪽(첨부 칸)이 옮긴다(revoked)
import { computed, ref } from 'vue'
import LargeFileRevokeDialog from '@/components/mail/LargeFileRevokeDialog.vue'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_REVOKE_TEXT, MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { copyText } from '@/shared/utils/clipboard'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** 한 링크(메일)의 파일 — 모두 같은 linkId · url */
files: MessageLargeFile[]
}
interface Emits {
/** 링크를 끊었다 — 이 줄이 사라진다 */
(e: 'revoked'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { revokeShareLink } = useLargeFiles()
const toast = useToast()
const authStore = useAuthStore()
const mailStore = useMailStore()
const confirming = ref<boolean>(false)
const revoking = ref<boolean>(false)
const linkId = computed(() => props.files[0]?.linkId ?? '')
const noteId = computed(() => `reader-large-link-${linkId.value}`)
const note = computed(() => {
const count = props.files.length
return `${MESSAGE_LARGE_FILE_TEXT.linkNote(formatCount(count))} ${MESSAGE_LARGE_FILE_TEXT.linkNoteCut(count)}`
})
const dialogMessage = computed(() =>
LARGE_FILE_REVOKE_TEXT.messageWithFiles(
formatCount(props.files.length),
props.files.map((file) => file.name).join(LARGE_FILE_REVOKE_TEXT.nameSeparator),
),
)
async function copyLink() {
const url = props.files[0]?.url
if (url && (await copyText(url))) toast.success(MESSAGE_LARGE_FILE_TEXT.copied)
else toast.error(MESSAGE_LARGE_FILE_TEXT.copyFailed)
}
async function revoke() {
if (revoking.value || !linkId.value) return
const session = authStore.generation
revoking.value = true
try {
const link = await revokeShareLink(linkId.value)
if (authStore.generation !== session) return
confirming.value = false
mailStore.applyLargeFileLink(link)
toast.success(LARGE_FILE_REVOKE_TEXT.done)
emit('revoked')
} catch {
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
} finally {
revoking.value = false
}
}
</script>
<template>
<div
class="link-bar"
:data-large-link="linkId"
>
<span
:id="noteId"
class="link-bar__note"
>{{ note }}</span>
<span class="link-bar__actions">
<button
type="button"
class="link-bar__button"
data-link-copy
@click="copyLink"
>
{{ MESSAGE_LARGE_FILE_TEXT.copyLink }}
</button>
<button
type="button"
class="link-bar__button link-bar__button--danger"
:aria-describedby="noteId"
data-link-revoke
@click="confirming = true"
>
{{ MESSAGE_LARGE_FILE_TEXT.revoke }}
</button>
</span>
<LargeFileRevokeDialog
:open="confirming"
:message="dialogMessage"
:busy="revoking"
id-prefix="reader-large-revoke"
@confirm="revoke"
@cancel="confirming = false"
/>
</div>
</template>
<style scoped>
.link-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
padding-top: var(--space-4);
}
.link-bar__note {
flex: 1 1 20rem;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.link-bar__actions {
flex: 0 0 auto;
display: inline-flex;
gap: var(--space-6);
}
/* 36 — 밀도 예외 (--size-notice-action, 차단한 주소의 [차단 풀기]와 같다) */
.link-bar__button {
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.link-bar__button:hover {
background: var(--color-surface-muted);
}
/* [링크 끊기] — 빨간 글 테두리 단추 */
.link-bar__button--danger {
color: var(--color-danger);
}
.link-bar__button--danger:hover {
background: var(--color-danger-surface);
}
</style>
@@ -12,6 +12,7 @@
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -70,6 +71,8 @@ const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 서버가 준다 (필드가 없는 서버면 없다) */
const largeFiles = computed(() => message.value?.largeFiles ?? [])
function retry() {
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
@@ -261,14 +264,16 @@ onBeforeUnmount(() => {
:html-body="message.htmlBody"
:text-body="message.textBody"
:images-shown="message.imagesShown === true"
:fold-large-files="largeFiles.length > 0"
:title="`메일 본문: ${subject}`"
@frame-keydown="onPrintKey"
/>
<MessageAttachments
v-if="attachments.length > 0"
v-if="attachments.length > 0 || largeFiles.length > 0"
:message-id="message.id"
:attachments="attachments"
:large-files="largeFiles"
/>
</div>
</article>
@@ -57,10 +57,10 @@ describe('MailEditor (서식 편집기)', () => {
it('빈 본문이면 텍스트 메일 — 서식 본문(html)을 보내지 않는다 (빈 줄만 있어도)', () => {
const view = mountEditor()
expect(view.vm.getContent()).toEqual({ text: '', html: null })
expect(view.vm.getContent()).toEqual({ text: '', html: null, quoteStart: null })
editorOf(view.element).commands.setContent('<p></p><p> </p><p></p>')
expect(view.vm.getContent()).toEqual({ text: '', html: null })
expect(view.vm.getContent()).toEqual({ text: '', html: null, quoteStart: null })
})
it('보낼 본문 — 서식 본문은 기본 글꼴 div 로 감싸고, 같은 내용의 텍스트를 함께 만든다', () => {
@@ -25,14 +25,15 @@ import { markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorToolbar from '@/components/mail/editor/EditorToolbar.vue'
import { INITIAL_DRAFT_LIMITS, fitDraftDoc, fitsLimits } from '@/components/mail/editor/fit-draft'
import { DRAFT_LIMITS, INITIAL_DRAFT_LIMITS, fitDraftDoc, fitsLimits, type DraftLimits } from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import type {
EditorSavePoint,
MailEditorContent,
MailEditorReserve,
MailEditorSnapshot,
} from '@/components/mail/editor/mail-editor-handle'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { docToTextWithQuoteStart, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { hasSignatureImage } from '@/components/mail/editor/signature-image'
import { DROP_TO_ATTACH_LABEL, MAIL_EDITOR_BODY_ID } from '@/shared/constants/mail-editor'
import { pastedFiles, pastedImageName, renameFile } from '@/shared/utils/editor-files'
@@ -145,17 +146,29 @@ const editor = markRaw(
}),
)
/** 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트 */
/**
* 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트. 원문 인용이 있으면 서식 본문에 인용 경계 표시를 두고
* 텍스트에서 인용이 시작하는 자리를 함께 준다 (11단계 — 대용량 첨부 덩이 자리)
*/
function contentOf(doc: ProseMirrorNode): MailEditorContent {
const text = docToText(doc)
const { text, quoteStart } = docToTextWithQuoteStart(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다.
// 글자 없이 서명 그림만 있으면 서식 본문으로 보낸다 (텍스트 대체 본문에는 그림이 없다)
if (text.trim() === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
if (text.trim() === '' && !hasSignatureImage(doc)) return { text: '', html: null, quoteStart: null }
return {
text,
html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema), { markQuoteStart: true }),
quoteStart,
}
}
function contentFits(content: MailEditorContent): boolean {
return fitsLimits({ text: content.text.length, html: content.html?.length ?? 0 })
/** 보내기 한도 — 남겨 둘 자리(대용량 첨부 덩이)만큼 텍스트 한도를 줄인다 */
function sendLimits(reserve?: MailEditorReserve): DraftLimits {
return reserve ? { text: DRAFT_LIMITS.text - reserve.text, html: DRAFT_LIMITS.html } : DRAFT_LIMITS
}
function contentFits(content: MailEditorContent, limits: DraftLimits = DRAFT_LIMITS): boolean {
return fitsLimits({ text: content.text.length, html: content.html?.length ?? 0 }, limits)
}
function getContent(): MailEditorContent {
@@ -180,14 +193,15 @@ function applyDoc(next: ProseMirrorNode) {
* 넘으면 원문 인용을 더 줄여(생략 표시) 맞추고 편집기에도 그 모양을 보인다 — 보낸 것과 화면이 같게.
* 원문 인용이 없거나 인용을 다 줄여도 넘으면(내 글만으로 넘는다) null — 편집기는 그대로 둔다.
*/
function getContentForSend(): MailEditorContent | null {
function getContentForSend(reserve?: MailEditorReserve): MailEditorContent | null {
const limits = sendLimits(reserve)
const current = getContent()
if (contentFits(current)) return current
if (contentFits(current, limits)) return current
const { doc } = editor.state
const fitted = fitDraftDoc(doc, editor.schema)
const fitted = fitDraftDoc(doc, editor.schema, limits)
if (fitted === doc) return null
const content = contentOf(fitted)
if (!contentFits(content)) return null
if (!contentFits(content, limits)) return null
applyDoc(fitted)
return content
}
@@ -1,4 +1,4 @@
import { createDocument, getSchema } from '@tiptap/core'
import { createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { describe, expect, it } from 'vitest'
@@ -12,7 +12,7 @@ import {
type DraftLimits,
} from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText } from '@/components/mail/editor/mail-serialize'
import { docToText, docToTextWithQuoteStart, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_MAX_LENGTH, MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
@@ -128,3 +128,59 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
expect(docToText(fitted).endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
})
/** 보낸 · 저장한 모양 — 편집기 HTML 에 인라인 style 을 입히고 data-mail-quote · data-quote-header 를 지운다(서버 정화도 data-* 를 지운다) */
function savedHtml(doc: ProseMirrorNode): string {
return toMailHtml(getHTMLFromFragment(doc.content, schema))
}
describe('저장본 · 보내기 취소로 되돌린 초안 — 정화로 표시가 지워진 원문 인용 (11단계 리뷰 M1)', () => {
it('다시 읽으면 맨 위 인용의 첫 자식이 "원본 메일" 머리 문단 + 머리 표이면 원문 인용이다', () => {
const html = savedHtml(draft(lines(3), '내 글'))
expect(html).not.toContain('data-mail-quote')
expect(html).not.toContain('data-quote-header')
const reopened = createDocument(html, schema)
expect(reopened.child(0).textContent).toBe('내 글')
const quote = reopened.child(1)
expect(quote.type.name).toBe('blockquote')
expect(quote.attrs.mailQuote).toBe(true)
expect(quote.child(0).attrs.quoteHeader).toBe(true)
expect(quote.child(1).attrs.quoteHeader).toBe(true)
// 덩이 자리(bodyQuoteStart)도 다시 센다 — 인용 첫 줄의 처음
const { text, quoteStart } = docToTextWithQuoteStart(reopened)
expect(quoteStart).not.toBeNull()
expect(text.slice(quoteStart ?? 0).startsWith('> 원본 메일')).toBe(true)
})
it('그래서 한도를 넘는 저장본도 보내기 직전에 원문 인용을 줄인다 (내 글은 그대로)', () => {
const reopened = createDocument(savedHtml(draft(lines(200), '내 글')), schema)
const fitted = fitDraftDoc(reopened, schema, { text: 600, html: 100_000 })
expect(fitted).not.toBe(reopened)
expect(fitted.child(0).textContent).toBe('내 글')
expect(quoteTexts(fitted).at(-1)).toBe(MAIL_QUOTE_OMITTED_MARK)
})
it('내가 쓴 인용 · 원문 인용 안의 옛 원문(답장의 답장)은 원문 인용이 아니다', () => {
const older = buildQuoteHtml(HEADER, '<p>옛 원문</p>').replace('<p></p>', '')
const nested = createDocument(
'<p>내 글</p><blockquote><p>내가 인용한 글</p></blockquote>' +
savedHtml(createDocument(buildQuoteHtml(HEADER, older), schema)),
schema,
)
const quotes: ProseMirrorNode[] = []
nested.forEach((child) => {
if (child.type.name === 'blockquote') quotes.push(child)
})
expect(quotes.map((quote) => quote.attrs.mailQuote)).toEqual([false, true])
const inner: ProseMirrorNode[] = []
quotes[1]?.descendants((node) => {
if (node.type.name === 'blockquote') inner.push(node)
})
expect(inner.map((quote) => quote.attrs.mailQuote)).toEqual([false])
})
})
@@ -42,11 +42,11 @@ export interface DraftSize {
html: number
}
/** 보낼 모양 그대로의 길이 */
/** 보낼 모양 그대로의 길이 — 서식 본문은 인용 경계 표시까지 (편집기가 보내는 모양 — MailEditor 의 contentOf 와 같다) */
export function measureDraft(doc: ProseMirrorNode, schema: Schema): DraftSize {
return {
text: docToText(doc).length,
html: toMailHtml(getHTMLFromFragment(doc.content, schema)).length,
html: toMailHtml(getHTMLFromFragment(doc.content, schema), { markQuoteStart: true }).length,
}
}
@@ -5,8 +5,19 @@ import type { InjectionKey } from 'vue'
export interface MailEditorContent {
/** 일반 텍스트 (body) — 서식 본문의 대체 본문. 비었으면 '' */
text: string
/** 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다) */
/**
* 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다).
* 원문 인용이 있으면 그 바로 앞에 인용 경계 표시(<div data-ara-quote-start></div>)가 있다 — 대용량 파일이 없는 메일은
* 쓰기 화면이 지우고 보낸다 (outgoing-html-rules.ts 의 withoutQuoteStartMark · 계약 4절 "발송 · 임시 저장과 함께")
*/
html: string | null
/** text 에서 원문 인용이 시작하는 자리(줄의 처음) — 인용이 없으면 null. 대용량 파일이 있을 때 bodyQuoteStart 로 보낸다 */
quoteStart: number | null
}
/** 보내기 직전에 본문 한도에서 남겨 둘 자리 — 서버가 넣을 대용량 첨부 덩이의 텍스트 길이 (11단계) */
export interface MailEditorReserve {
text: number
}
/**
@@ -29,8 +40,9 @@ export interface MailEditorHandle {
/**
* 보내기 직전의 본문 — 한도(텍스트 100,000 · 서식 500,000)를 최종 직렬화 결과로 잰다.
* 넘으면 원문 인용을 더 줄여 맞추고 편집기에도 그 모양을 보인다(생략 표시 유지). 내 글만으로 넘으면 null — 보내지 않는다.
* reserve 를 주면 텍스트 한도에서 그만큼을 남긴다 — 서버가 대용량 첨부 덩이를 넣은 뒤 다시 재기 때문이다 (계약 4절 "덩이")
*/
getContentForSend: () => MailEditorContent | null
getContentForSend: (reserve?: MailEditorReserve) => MailEditorContent | null
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
focus: (position?: 'start' | 'end') => void
/**
@@ -79,6 +79,19 @@ function isQuoteHeaderTable(element: HTMLElement): boolean {
return previous?.tagName === 'P' && previous.textContent?.trim() === MAIL_QUOTE_HEADING
}
/**
* 원문 인용인가 — 우리가 만든 초안은 data-mail-quote 로 표시한다.
* 임시 저장본 · 보내기 취소로 되돌린 초안은 서버 정화가 data-* 를 지우므로, 다시 열면 "맨 위 인용의 첫 자식이
* '원본 메일' 머리 문단 + 바로 뒤 머리 표"로 알아본다 (isQuoteHeadingParagraph 와 같은 방식). 그래야 인용 경계 표시 ·
* bodyQuoteStart(11단계 — 덩이 자리)와 한도를 넘는 초안의 인용 줄이기(fitDraftDoc)가 저장본에서도 돈다.
* 인용 안의 인용(답장의 답장 속 옛 원문)은 원문 인용이 아니다 — 맨 위 것만
*/
function isMailQuoteBlock(element: HTMLElement): boolean {
if (element.parentElement?.closest('blockquote')) return false
const first = element.firstElementChild
return first?.tagName === 'P' && isQuoteHeadingParagraph(first as HTMLElement)
}
/** 참/거짓 표시 속성 — HTML 에서는 data-* 로 오간다 (renderHTML 은 노드의 속성 전체를 받는다) */
function flagAttribute(name: string, dataName: string, detect: (element: HTMLElement) => boolean) {
return {
@@ -142,7 +155,7 @@ const MailEditorBehavior = Extension.create<MailEditorHooks>({
},
{
types: ['blockquote'],
attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', () => false) },
attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', isMailQuoteBlock) },
},
]
},
@@ -5,11 +5,14 @@ import { createMailEditorExtensions } from '@/components/mail/editor/mail-extens
import {
MAIL_HTML_STYLES,
docToText,
docToTextWithQuoteStart,
normalizeIncomingHtml,
toMailHtml,
toMailHtmlFragment,
} from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_LINE_HEIGHT } from '@/shared/constants/mail-editor'
import { buildQuoteHtml } from '@/shared/utils/mail-quote'
import { QUOTE_START_MARK, withoutQuoteStartMark } from '@/shared/utils/outgoing-html-rules'
const schema = getSchema(createMailEditorExtensions())
@@ -169,6 +172,52 @@ describe('toMailHtml — 받는 쪽 모양을 인라인 style 로 (문단 사이
})
})
describe('인용 경계 — 대용량 첨부 덩이 자리 (11단계 — 계약 4절 "발송 · 임시 저장과 함께")', () => {
const quoted = (signature = '') =>
createDocument(
`<p>확인했습니다.</p>${signature}` +
buildQuoteHtml([{ label: '보낸 사람', value: '이도윤 <doyun.lee@arasystem.it.kr>' }], '<p>원문</p>'),
schema,
)
it('서식 본문 — 원문 인용 바로 앞에 빈 표시 하나 (markQuoteStart). 표시를 빼면 지금 모양 그대로', () => {
const doc = quoted('<p>김아라 · 인프라팀</p>')
const editorHtml = getHTMLFromFragment(doc.content, schema)
const html = toMailHtml(editorHtml, { markQuoteStart: true })
expect(html.match(/data-ara-quote-start/g)).toHaveLength(1)
expect(html).toContain(`${QUOTE_START_MARK}<blockquote`)
// 내 글 · 서명 아래
expect(html.indexOf('김아라 · 인프라팀')).toBeLessThan(html.indexOf(QUOTE_START_MARK))
expect(withoutQuoteStartMark(html)).toBe(toMailHtml(editorHtml))
})
it('원문 인용이 없으면 표시도 없다 — 사용자가 쓴 인용(막대)은 원문 인용이 아니다', () => {
const doc = createDocument('<p>글</p><blockquote><p>내가 쓴 인용</p></blockquote>', schema)
expect(toMailHtml(getHTMLFromFragment(doc.content, schema), { markQuoteStart: true })).not.toContain(
'data-ara-quote-start',
)
expect(docToTextWithQuoteStart(doc).quoteStart).toBeNull()
})
it('텍스트 — 인용 첫 줄의 처음(줄의 처음)을 알린다. 글은 docToText 와 같다', () => {
const doc = quoted('<p>김아라 · 인프라팀</p>')
const { text: body, quoteStart } = docToTextWithQuoteStart(doc)
expect(body).toBe(docToText(doc))
expect(quoteStart).not.toBeNull()
expect(body.slice(0, quoteStart ?? 0)).toBe(['확인했습니다.', '김아라 · 인프라팀', '', ''].join('\n'))
expect(body.slice(quoteStart ?? 0).startsWith('> 원본 메일')).toBe(true)
})
it('인용이 맨 앞이면 0', () => {
const doc = createDocument(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>').replace('<p></p>', ''), schema)
expect(docToTextWithQuoteStart(doc).quoteStart).toBe(0)
})
})
describe('남의 HTML 을 화면 문서로 옮기지 않는다 (붙여 넣기 · 보낼 모양 — 처리기 실행 방지)', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -7,6 +7,8 @@
// 서버는 받은 HTML 을 다시 허용 목록으로 정화한다(sanitize-outgoing-html.ts). 여기서 넣는 style 은 모두 그 목록 안의 값이다
// (shared/utils/outgoing-html-rules.ts — 서버와 같은 형식, outgoing-html-rules.spec.ts 가 지킨다).
// HTML 조각은 화면과 떨어진 문서에서만 다룬다 — 남의 HTML 의 처리기가 돌지 않게 (rewriteInert).
// 인용 경계(11단계 — 계약 4절 "발송 · 임시 저장과 함께"): 서버가 대용량 첨부 덩이를 내 글 · 서명 아래, 인용 위에 넣도록
// 서식 본문은 원문 인용 바로 앞에 <div data-ara-quote-start></div>(markQuoteStart), 텍스트는 인용이 시작하는 자리(docToTextWithQuoteStart)를 알린다.
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { MAIL_QUOTE_LINE_PREFIX } from '@/shared/constants/mail'
@@ -15,7 +17,7 @@ import {
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
import { listStartOf } from '@/shared/utils/outgoing-html-rules'
import { QUOTE_START_ATTRIBUTE, listStartOf } from '@/shared/utils/outgoing-html-rules'
// ── 텍스트 ───────────────────────────────────────────────
@@ -126,6 +128,31 @@ export function docToText(doc: ProseMirrorNode): string {
return childLines(doc).join('\n').replace(/\s+$/, '')
}
/** 원문 인용 — 맨 위 단계의 data-mail-quote 인용 (사용자가 쓴 인용과 다르다) */
function isMailQuote(node: ProseMirrorNode): boolean {
return node.type.name === 'blockquote' && Boolean(node.attrs.mailQuote)
}
/**
* 텍스트 본문과 원문 인용이 시작하는 자리 — 인용 첫 줄의 처음(JS 문자열 위치)이다. 인용이 없으면 null.
* 서버는 이 자리에 대용량 첨부 덩이를 넣는다 (bodyQuoteStart — 0 이거나 바로 앞이 줄바꿈이어야 한다). 글은 docToText 와 같다
*/
export function docToTextWithQuoteStart(doc: ProseMirrorNode): { text: string; quoteStart: number | null } {
const lines: string[] = []
let quoteLine = -1
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (quoteLine < 0 && isMailQuote(child)) quoteLine = lines.length
lines.push(...blockLines(child))
// 인용 머리 표 뒤 한 줄 — childLines 와 같다
if (isQuoteHeaderTable(child)) lines.push('')
}
const text = lines.join('\n').replace(/\s+$/, '')
if (quoteLine < 0) return { text, quoteStart: null }
const start = lines.slice(0, quoteLine).reduce((sum, line) => sum + line.length + 1, 0)
return { text, quoteStart: start <= text.length ? start : null }
}
// ── HTML ─────────────────────────────────────────────────
/** 받는 쪽에서도 편집기와 같은 모양이 되도록 넣는 인라인 style (디자인 README "답장 · 전달의 원문 인용" 값) */
@@ -289,9 +316,32 @@ function trimTrailingEmptyParagraphs(editorHtml: string): string {
return editorHtml.replace(/(?:<p(?: [^>]*)?><\/p>)+$/, '')
}
/** 원문 인용 바로 앞에 인용 경계 표시를 둔다 — 맨 위 단계의 첫 원문 인용 (서버는 첫 표시 자리를 쓴다) */
function insertQuoteStartMark(root: DocumentFragment, inert: Document) {
const quote = Array.from(root.children).find(
(element) => element.tagName === 'BLOCKQUOTE' && element.hasAttribute('data-mail-quote'),
)
if (!quote) return
const mark = inert.createElement('div')
mark.setAttribute(QUOTE_START_ATTRIBUTE, '')
quote.before(mark)
}
export interface MailHtmlOptions {
/**
* 원문 인용 바로 앞에 인용 경계 표시(<div data-ara-quote-start></div>)를 둔다 — 대용량 첨부 덩이 자리 (11단계).
* 대용량 파일이 없는 메일은 쓰기 화면이 표시를 지우고 보낸다 (withoutQuoteStartMark)
*/
markQuoteStart?: boolean
}
/** 보내는 서식 본문 — 기본 글꼴 · 글자 크기 · 줄 간격 div 로 감싼다 (계약 2절 "서식 본문", 편집기 화면과 같은 값) */
export function toMailHtml(editorHtml: string): string {
const inner = toMailHtmlFragment(trimTrailingEmptyParagraphs(editorHtml))
export function toMailHtml(editorHtml: string, options: MailHtmlOptions = {}): string {
const inner = rewriteInert(trimTrailingEmptyParagraphs(editorHtml), (root, inert) => {
// 표시를 먼저 둔다 — 아래 모양 입히기가 data-mail-quote 를 지운다
if (options.markQuoteStart) insertQuoteStartMark(root, inert)
applyMailStyles(root, inert)
})
const wrapper = [
`font-family: ${MAIL_BODY_FONT_FAMILY}`,
`font-size: ${MAIL_BODY_FONT_SIZE_PX}px`,
@@ -172,12 +172,12 @@ function insertImage(image: SignatureImage) {
.run()
}
/** 서명의 보낼 모양 — 쓰기 본문과 같은 서식 본문 · 텍스트. 비었으면 둘 다 비운다 */
/** 서명의 보낼 모양 — 쓰기 본문과 같은 서식 본문 · 텍스트. 비었으면 둘 다 비운다 (서명에는 원문 인용이 없다) */
function getContent(): MailEditorContent {
const { doc } = editor.state
const text = docToText(doc)
if (text === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
if (text === '' && !hasSignatureImage(doc)) return { text: '', html: null, quoteStart: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)), quoteStart: null }
}
/** 서명이 쓰는 그림 id */
@@ -0,0 +1,62 @@
<script setup lang="ts">
// 보내지 않은 파일 지우기 확인 — alertdialog 400 (디자인 원본 SettingsLargeFiles)
// "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. …지운 파일은 되돌릴 수 없습니다." [지우기](빨강) [취소]
// 되돌릴 수 없는 일이라 처음 초점은 「취소」, Esc 도 취소다. 지우는 동안은 [지우기]를 aria-disabled 로 막는다
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
interface Props {
open: boolean
message: string
busy?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
function onConfirm() {
if (!props.busy) emit('confirm')
}
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
medium
:title="LARGE_FILE_SETTINGS_TEXT.deleteTitle"
:message="message"
title-id="large-delete-title"
message-id="large-delete-message"
@cancel="emit('cancel')"
>
<template #actions>
<button
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
data-delete-confirm
@click="onConfirm"
>
{{ LARGE_FILE_SETTINGS_TEXT.deleteConfirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
data-delete-cancel
@click="emit('cancel')"
>
{{ LARGE_FILE_SETTINGS_TEXT.deleteCancel }}
</button>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,529 @@
<script setup lang="ts">
// 설정 › 대용량 첨부 › 보낸 링크 (디자인 원본 SettingsLargeFiles) — 메일(링크)마다 한 덩이, 최근에 보낸 차례 · 20개씩 [더 보기]
// 머리: 메일 제목(청록 링크 → 보낸편지함의 그 메일, 보낸 메일을 모르면 글자만) / "10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB" ·
// 오른쪽 "11월 29일까지" / "30일 남음"(3일 이하면 갈색 굵게) · [링크 끊기](36 — 링크가 메일 한 통에 하나라 덩이에 한 번)
// 파일 줄(44 · 바탕 #FBFBFC): 아이콘 · 이름 · 크기 · "3회 받음" / "마지막 오늘 오후 2:41"(받지 않았으면 "아직 받지 않음") · 상태(뱃지 없이 글자만)
// 끝난 링크는 덩이째 흐리게 — 머리 오른쪽 "공유 멈춤 / 10월 22일에 끊음"(끊은 때가 없는 옛 응답은 "공유 멈춤"만) · "기간 지남 / 10월 25일까지였습니다", 단추 없음.
// [링크 끊기] → 읽기 화면과 같은 확인 창(창 글에 메일 제목) · 같은 알림. 끊는 동안 그 덩이의 단추를 막고, 끝나면 초점을 덩이 머리로.
// [링크 복사]는 두지 않는다 — 서버는 링크를 해시로만 알아 원래 주소를 모른다(보낸 메일의 읽기 화면에서 복사한다)
import { computed, nextTick, ref } from 'vue'
import { RouterLink } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import LargeFileRevokeDialog from '@/components/mail/LargeFileRevokeDialog.vue'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_REVOKE_TEXT, LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { ShareLink, SharedFile } from '@/shared/types/large-file'
import { formatCount, formatFileSize, formatRelativeDayTime } from '@/shared/utils/format'
import { formatKstMonthDay, formatKstSentDay, kstDaysLeft } from '@/shared/utils/kst-date'
import { sharedFileStatusView } from '@/shared/utils/large-file-status'
import { useLargeFilesStore } from '@/stores/large-files.store'
interface Props {
links: ShareLink[]
/** 보낸 링크 수 — 서버의 total */
total: number
hasMore: boolean
loadingMore: boolean
/** 지금 시각(ms) — 남은 날 · "오늘 오후 2:41" */
now: number
/** 보낸편지함 id — 메일 제목을 그 메일로 잇는다 (모르면 글자만) */
sentMailboxId: string | null
}
interface Emits {
(e: 'more'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const largeFilesStore = useLargeFilesStore()
const toast = useToast()
/** 남은 날이 이만큼 이하면 갈색 굵게 */
const SOON_DAYS = 3
const listRef = ref<HTMLUListElement | null>(null)
/** 끊을지 묻는 링크 */
const target = ref<ShareLink | null>(null)
const revoking = ref<boolean>(false)
function subjectOf(link: ShareLink): string {
return link.subject || LARGE_FILE_SETTINGS_TEXT.noSubject
}
function sentLineOf(link: ShareLink): string {
const total = link.files.reduce((sum, file) => sum + file.size, 0)
return LARGE_FILE_SETTINGS_TEXT.sentLine(
formatKstSentDay(link.sentAt),
formatCount(link.files.length),
formatFileSize(total),
)
}
/** 제목을 누르면 갈 보낸 메일 — 메일을 지웠으면(서버가 제목을 읽지 못해 null) 글자만 (README "메일을 지웠으면 글자만") */
function mailLocationOf(link: ShareLink) {
if (!link.sentEmailId || link.subject === null || !props.sentMailboxId) return null
return { name: 'mail-message', params: { mailboxId: props.sentMailboxId, messageId: link.sentEmailId } }
}
/**
* 머리 오른쪽 두 줄 — 끝나는 날 · 남은 날 / 공유 멈춤 · 끊은 날 / 기간 지남 · 끝난 날.
* 끊은 날은 한국 날짜("10월 22일에 끊음") — 응답에 끊은 때가 없으면(옛 서버) "공유 멈춤"만
*/
function untilOf(link: ShareLink): { main: string; note: string | null; soon: boolean } {
if (link.status === 'revoked') {
const revokedDay = link.revokedAt ? formatKstMonthDay(link.revokedAt) : ''
return {
main: LARGE_FILE_SETTINGS_TEXT.linkRevoked,
note: revokedDay ? LARGE_FILE_SETTINGS_TEXT.revokedOn(revokedDay) : null,
soon: false,
}
}
const date = formatKstMonthDay(link.expiresAt)
if (link.status === 'expired') {
return { main: LARGE_FILE_SETTINGS_TEXT.linkExpired, note: LARGE_FILE_SETTINGS_TEXT.expiredUntil(date), soon: false }
}
const days = kstDaysLeft(link.expiresAt, props.now) ?? 0
return {
main: LARGE_FILE_SETTINGS_TEXT.until(date),
note: days > 0 ? LARGE_FILE_SETTINGS_TEXT.daysLeft(formatCount(days)) : LARGE_FILE_SETTINGS_TEXT.lastDay,
soon: days <= SOON_DAYS,
}
}
function downloadsOf(file: SharedFile): { main: string; note: string | null } {
if (file.downloadCount <= 0) return { main: LARGE_FILE_SETTINGS_TEXT.notDownloaded, note: null }
return {
main: LARGE_FILE_SETTINGS_TEXT.downloads(formatCount(file.downloadCount)),
note: file.lastDownloadedAt
? LARGE_FILE_SETTINGS_TEXT.lastDownloaded(formatRelativeDayTime(file.lastDownloadedAt, new Date(props.now)))
: null,
}
}
const dialogMessage = computed(() =>
target.value
? LARGE_FILE_REVOKE_TEXT.messageWithSubject(subjectOf(target.value), formatCount(target.value.files.length))
: '',
)
/** 끊은 덩이의 머리로 초점 — 누른 단추가 사라졌다 */
async function focusHead(linkId: string) {
await nextTick()
const block = Array.from(listRef.value?.querySelectorAll<HTMLElement>('[data-share-link]') ?? []).find(
(element) => element.dataset.shareLink === linkId,
)
block?.querySelector<HTMLElement>('.link__head')?.focus()
}
async function confirmRevoke() {
const link = target.value
if (!link || revoking.value) return
revoking.value = true
try {
await largeFilesStore.revokeLink(link.id)
target.value = null
toast.success(LARGE_FILE_REVOKE_TEXT.done)
await focusHead(link.id)
} catch {
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
} finally {
revoking.value = false
}
}
</script>
<template>
<section
class="links"
aria-labelledby="large-links-title"
>
<div class="links__head">
<h2
id="large-links-title"
class="links__title"
>
{{ LARGE_FILE_SETTINGS_TEXT.linksTitle(formatCount(total)) }}
</h2>
<span class="links__hint">{{ LARGE_FILE_SETTINGS_TEXT.linksHint }}</span>
</div>
<ul
ref="listRef"
class="links__list"
:aria-label="LARGE_FILE_SETTINGS_TEXT.linksList"
>
<li
v-for="link in links"
:key="link.id"
:class="['link', { 'link--ended': link.status !== 'active' }]"
:data-share-link="link.id"
>
<div
class="link__head"
tabindex="-1"
>
<div class="link__title-col">
<RouterLink
v-if="mailLocationOf(link)"
class="link__subject"
:to="mailLocationOf(link) ?? ''"
>
{{ subjectOf(link) }}
</RouterLink>
<span
v-else
class="link__subject link__subject--plain"
>{{ subjectOf(link) }}</span>
<span class="link__sent">{{ sentLineOf(link) }}</span>
</div>
<div class="link__until">
<span class="link__until-main">{{ untilOf(link).main }}</span>
<span
v-if="untilOf(link).note"
:class="['link__until-note', { 'link__until-note--soon': untilOf(link).soon }]"
>{{ untilOf(link).note }}</span>
</div>
<button
v-if="link.status === 'active'"
type="button"
class="link__revoke"
:aria-label="LARGE_FILE_SETTINGS_TEXT.revokeLabel(subjectOf(link))"
:aria-disabled="revoking && target?.id === link.id ? 'true' : undefined"
data-link-revoke
@click="target = link"
>
{{ LARGE_FILE_REVOKE_TEXT.confirm }}
</button>
</div>
<ul class="link__files">
<li
v-for="file in link.files"
:key="file.id"
:class="['link__file', { 'link__file--danger': file.status === 'infected' }]"
:data-shared-file="file.id"
>
<AppIcon
:name="file.status === 'infected' ? 'alert-triangle' : 'file'"
class="link__file-icon"
/>
<span class="link__file-name">{{ file.name }}</span>
<span class="link__file-size">{{ formatFileSize(file.size) }}</span>
<span class="link__file-downloads">
<span>{{ downloadsOf(file).main }}</span>
<span
v-if="downloadsOf(file).note"
class="link__file-note"
>{{ downloadsOf(file).note }}</span>
</span>
<span :class="['link__file-status', `link__file-status--${sharedFileStatusView(file).tone}`]">
<span>{{ sharedFileStatusView(file).label }}</span>
<span
v-if="sharedFileStatusView(file).note"
class="link__file-status-note"
>{{ sharedFileStatusView(file).note }}</span>
</span>
</li>
</ul>
</li>
</ul>
<div
v-if="hasMore"
class="links__more"
>
<button
type="button"
class="links__more-button"
:aria-disabled="loadingMore ? 'true' : undefined"
:aria-busy="loadingMore ? 'true' : undefined"
data-links-more
@click="loadingMore || emit('more')"
>
{{ LARGE_FILE_SETTINGS_TEXT.more }}
</button>
<span class="links__more-hint">{{ LARGE_FILE_SETTINGS_TEXT.moreHint }}</span>
</div>
<LargeFileRevokeDialog
:open="target !== null"
:message="dialogMessage"
:busy="revoking"
id-prefix="settings-large-revoke"
@confirm="confirmRevoke"
@cancel="target = null"
/>
</section>
</template>
<style scoped>
.links {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.links__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
}
.links__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.links__hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.links__list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.link {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-10) var(--space-8) var(--space-10) var(--space-14);
}
.link + .link {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.link__head {
display: flex;
align-items: center;
gap: var(--space-12);
border-radius: var(--radius-sm);
outline-offset: var(--focus-ring-offset);
}
.link__title-col {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.link__subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--color-brand-strong);
text-decoration: none;
}
.link__subject:hover {
text-decoration: underline;
}
.link__subject--plain {
color: var(--color-text);
}
.link__sent {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.link__until {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: var(--line-height-tight);
font-variant-numeric: tabular-nums;
}
.link__until-main {
font-weight: 500;
}
.link__until-note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* 남은 날이 3일 이하 — 갈색 굵게 */
.link__until-note--soon {
font-weight: 600;
color: var(--color-warning-text);
}
/* [링크 끊기] 36 — 밀도 예외 (--size-notice-action), 빨간 글 테두리 단추 */
.link__revoke {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-danger);
font-weight: 600;
}
.link__revoke:hover:not([aria-disabled='true']) {
background: var(--color-danger-surface);
}
.link__revoke[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.link__files {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: var(--space-4) 0 0;
padding: 0;
list-style: none;
}
/* 파일 줄 44 — 아이콘 16 · 이름 · 크기 64 · 받은 수 160 · 상태 112 (디자인 원본 SettingsLargeFiles) */
.link__file {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr) 4rem 10rem 7rem;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
padding: 0 var(--space-8);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.link__file--danger {
background: var(--color-danger-surface);
}
.link__file-icon {
color: var(--color-text-secondary);
}
.link__file--danger .link__file-icon {
color: var(--color-danger);
}
.link__file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.link__file-size {
font-size: var(--font-size-small);
color: var(--color-text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.link__file-downloads,
.link__file-status {
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
font-variant-numeric: tabular-nums;
}
.link__file-note,
.link__file-status-note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.link__file-status--normal {
color: var(--color-text-strong);
}
.link__file-status--muted {
color: var(--color-text-muted);
}
/* 검사하지 못함 — 상태 아래 갈색 */
.link__file-status--warning {
color: var(--color-text-strong);
}
.link__file-status--warning .link__file-status-note {
color: var(--color-warning-text);
}
/* 위험 — 빨강 굵게 + "내려받기를 막음" */
.link__file-status--danger {
font-weight: 600;
color: var(--color-danger);
}
.link__file-status--danger .link__file-status-note {
font-weight: 400;
color: var(--color-danger);
}
/* 끝난 링크 — 덩이째 흐리게 (투명도가 아니라 글자색) */
.link--ended .link__subject,
.link--ended .link__file-name,
.link--ended .link__until-main {
color: var(--color-text-muted);
}
.link--ended .link__file-icon {
color: var(--color-icon-muted);
}
.links__more {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
}
.links__more-button {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.links__more-button:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.links__more-button[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.links__more-hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 좁으면 파일 줄의 받은 수 · 상태를 이름 아래로 */
@media (max-width: 37.5rem) {
.link__file {
grid-template-columns: 1rem minmax(0, 1fr) auto;
padding: var(--space-6) var(--space-8);
}
.link__file-downloads,
.link__file-status {
grid-column: 2 / span 2;
}
}
</style>
@@ -0,0 +1,324 @@
<script setup lang="ts">
// 설정 › 대용량 첨부 › 보내지 않은 파일 (디자인 원본 SettingsLargeFiles — 사용량 상자 바로 아래: 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위)
// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다.
// 줄(52): 아이콘 · 이름 / 상태(올리다 멈춤 — 갈색 · 다 올림 · 검사 상태) · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") ·
// 남는 기한("11월 29일까지 남음", 멈춘 파일은 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글)
// [지우기] → 확인 창(처음 초점 「취소」) → DELETE /file-uploads/{id} → "‘…’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다(스토어).
// 초점은 그 자리의 다음 줄(없으면 앞 줄 · 묶음 제목)로.
// 실린 저장본의 제목은 보이지 않는다 — GET /file-uploads 응답에 저장본이 없다(계약 4절 LargeFile). 확인 창 글도 저장본 제목 없이 쓴다
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LargeFileDeleteDialog from '@/components/settings/LargeFileDeleteDialog.vue'
import { toApiError } from '@/composables/useApi'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { LargeFile } from '@/shared/types/large-file'
import { formatCount, formatFileSize, formatRelativeDayTime } from '@/shared/utils/format'
import { formatKstMonthDay } from '@/shared/utils/kst-date'
import { receivedBytesOf } from '@/shared/utils/large-file'
import { useLargeFilesStore } from '@/stores/large-files.store'
interface Props {
files: LargeFile[]
/** 지금 시각(ms) — "내일 오후 2:41" 을 셈한다 */
now: number
}
const props = defineProps<Props>()
const largeFilesStore = useLargeFilesStore()
const toast = useToast()
const titleRef = ref<HTMLHeadingElement | null>(null)
const listRef = ref<HTMLUListElement | null>(null)
/** 지울지 묻는 파일 */
const target = ref<LargeFile | null>(null)
const deleting = ref<boolean>(false)
/** 보관량을 잡는 양 — 위험 판정 파일은 곧 지워져 빠진다 */
const totalBytes = computed(() =>
props.files.reduce((sum, file) => (file.scanStatus === 'infected' ? sum : sum + file.size), 0),
)
interface RowView {
status: string
tone: 'muted' | 'warning' | 'danger'
/** 크기 아래 "420 MB 올림" — 올리다 멈춘 파일만 */
uploaded: string | null
until: string
untilNote: string | null
}
function rowOf(file: LargeFile): RowView {
const uploading = file.status === 'uploading'
const until = uploading
? LARGE_FILE_SETTINGS_TEXT.remainUntil(formatRelativeDayTime(file.expiresAt, new Date(props.now)))
: LARGE_FILE_SETTINGS_TEXT.remainUntil(formatKstMonthDay(file.expiresAt))
if (uploading) {
return {
status: LARGE_FILE_SETTINGS_TEXT.unsentUploading,
tone: 'warning',
uploaded: LARGE_FILE_SETTINGS_TEXT.partUploaded(
formatFileSize(receivedBytesOf(file.receivedChunks, file.chunkSize, file.size)),
),
until,
untilNote: LARGE_FILE_SETTINGS_TEXT.afterLastChunk,
}
}
const scan = file.scanStatus
if (scan === 'infected') {
return { status: LARGE_FILE_SETTINGS_TEXT.unsentInfected, tone: 'danger', uploaded: null, until, untilNote: null }
}
const status =
scan === 'pending'
? `${LARGE_FILE_SETTINGS_TEXT.unsentReady} · ${LARGE_FILE_SETTINGS_TEXT.unsentScanning}`
: scan === 'unscannable'
? `${LARGE_FILE_SETTINGS_TEXT.unsentReady} · ${LARGE_FILE_SETTINGS_TEXT.unsentUnscannable}`
: LARGE_FILE_SETTINGS_TEXT.unsentReady
return { status, tone: scan === 'unscannable' ? 'warning' : 'muted', uploaded: null, until, untilNote: null }
}
const deleteMessage = computed(() =>
target.value ? LARGE_FILE_SETTINGS_TEXT.deleteMessage(target.value.name, formatFileSize(target.value.size)) : '',
)
/** 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 묶음 제목 */
async function focusAfterRemoval(index: number) {
await nextTick()
const active = document.activeElement
if (active !== null && active !== document.body) return
const buttons = Array.from(listRef.value?.querySelectorAll<HTMLButtonElement>('[data-unsent-delete]') ?? [])
const next = buttons[index] ?? buttons[index - 1]
if (next) next.focus()
else titleRef.value?.focus()
}
async function confirmDelete() {
const file = target.value
if (!file || deleting.value) return
const index = props.files.findIndex((candidate) => candidate.id === file.id)
deleting.value = true
try {
await largeFilesStore.deleteUnsent(file.id)
target.value = null
toast.success(LARGE_FILE_SETTINGS_TEXT.deleted(file.name))
await focusAfterRemoval(index)
} catch (caught) {
// 인터셉터가 알렸다. 그사이 보낸 메일의 링크에 실린 파일(FILE_003)이면 목록을 다시 받았다 — 다시 눌러도 같으니 창을 닫는다.
// 그 밖은 창을 그대로 두어 다시 누를 수 있게 한다
if (toApiError(caught).code === 'FILE_003') {
target.value = null
await focusAfterRemoval(index)
}
} finally {
deleting.value = false
}
}
</script>
<template>
<section
class="unsent"
aria-labelledby="large-unsent-title"
>
<div class="unsent__head">
<h2
id="large-unsent-title"
ref="titleRef"
class="unsent__title"
tabindex="-1"
>
{{ LARGE_FILE_SETTINGS_TEXT.unsentTitle(formatCount(files.length)) }}
</h2>
<span class="unsent__hint">{{ LARGE_FILE_SETTINGS_TEXT.unsentHint(formatFileSize(totalBytes)) }}</span>
</div>
<ul
ref="listRef"
class="unsent__list"
:aria-label="LARGE_FILE_SETTINGS_TEXT.unsentList"
>
<li
v-for="file in files"
:key="file.id"
class="unsent__row"
:data-unsent="file.id"
>
<AppIcon
name="file"
class="unsent__icon"
/>
<span class="unsent__main">
<span class="unsent__name">{{ file.name }}</span>
<span :class="['unsent__status', `unsent__status--${rowOf(file).tone}`]">{{ rowOf(file).status }}</span>
</span>
<span class="unsent__size">
<span>{{ formatFileSize(file.size) }}</span>
<span v-if="rowOf(file).uploaded">{{ rowOf(file).uploaded }}</span>
</span>
<span class="unsent__until">
<span>{{ rowOf(file).until }}</span>
<span
v-if="rowOf(file).untilNote"
class="unsent__until-note"
>{{ rowOf(file).untilNote }}</span>
</span>
<button
type="button"
class="unsent__delete"
:aria-label="LARGE_FILE_SETTINGS_TEXT.deleteLabel(file.name)"
data-unsent-delete
@click="target = file"
>
{{ LARGE_FILE_SETTINGS_TEXT.delete }}
</button>
</li>
</ul>
<LargeFileDeleteDialog
:open="target !== null"
:message="deleteMessage"
:busy="deleting"
@confirm="confirmDelete"
@cancel="target = null"
/>
</section>
</template>
<style scoped>
.unsent {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.unsent__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
}
.unsent__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
outline-offset: var(--focus-ring-offset);
}
.unsent__hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.unsent__list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
/* 줄 52 — 아이콘 16 · 이름 · 크기 84 · 남는 기한 176 · [지우기] (디자인 원본 SettingsLargeFiles) */
.unsent__row {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr) 5.25rem 11rem auto;
align-items: center;
gap: var(--space-10);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-14);
}
.unsent__row + .unsent__row {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.unsent__icon {
color: var(--color-text-secondary);
}
.unsent__main,
.unsent__until {
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.unsent__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.unsent__status {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
}
.unsent__status--muted {
color: var(--color-text-muted);
}
.unsent__status--warning {
color: var(--color-warning-text);
}
.unsent__status--danger {
color: var(--color-danger);
}
.unsent__size {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: var(--line-height-tight);
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.unsent__until {
font-variant-numeric: tabular-nums;
}
.unsent__until-note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* [지우기] 36 — 밀도 예외 (--size-notice-action, 차단한 주소의 [차단 풀기]와 같다), 빨간 글 */
.unsent__delete {
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-danger);
font-weight: 600;
}
.unsent__delete:hover {
background: var(--color-danger-surface);
}
/* 좁으면 크기 · 남는 기한을 이름 아래로 */
@media (max-width: 37.5rem) {
.unsent__row {
grid-template-columns: 1rem minmax(0, 1fr) auto;
}
.unsent__size,
.unsent__until {
grid-column: 2;
align-items: flex-start;
}
.unsent__delete {
grid-column: 3;
grid-row: 1;
}
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
// 설정 › 대용량 첨부의 사용량 상자 (디자인 원본 SettingsLargeFiles)
// "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) +
// "보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다."
// 둘로 나누는 값은 보내지 않은 파일을 다 받았을 때만 (split) — 못 나누면 뒤 문장만. 막대는 <meter> 를 꾸민다 (값 · 범위가 화면 낭독기에 그대로 간다)
import { computed } from 'vue'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import { MAIL_QUOTA_WARNING_RATIO } from '@/shared/constants/mail'
import { formatFileSize, formatQuotaUsage } from '@/shared/utils/format'
interface Props {
usedBytes: number
limitBytes: number
/** 보낸 링크 · 보내지 않은 파일로 나눈 양 — 모르면 null */
split: { sentBytes: number; unsentBytes: number } | null
}
const props = defineProps<Props>()
const PERCENT = 100
const usage = computed(() => formatQuotaUsage(props.usedBytes, props.limitBytes))
const used = computed(() => Math.min(Math.max(props.usedBytes, 0), props.limitBytes))
const warnAt = computed(() => props.limitBytes * MAIL_QUOTA_WARNING_RATIO)
const percentText = computed(() =>
props.limitBytes > 0 ? `${Math.round((used.value / props.limitBytes) * PERCENT)}%` : '',
)
const note = computed(() => {
const tail = LARGE_FILE_SETTINGS_TEXT.usageNote
if (!props.split) return tail
const head = LARGE_FILE_SETTINGS_TEXT.usageSplit(
formatFileSize(props.split.sentBytes),
formatFileSize(props.split.unsentBytes),
)
return `${head} ${tail}`
})
</script>
<template>
<section
class="large-usage"
aria-labelledby="large-usage-label"
>
<div class="large-usage__row">
<span
id="large-usage-label"
class="large-usage__label"
>{{ LARGE_FILE_SETTINGS_TEXT.usageLabel }}</span>
<span
class="large-usage__value"
data-large-usage
>{{ usage }}</span>
</div>
<meter
v-if="limitBytes > 0"
class="large-usage__meter"
min="0"
:max="limitBytes"
:low="warnAt"
:high="warnAt"
optimum="0"
:value="used"
aria-labelledby="large-usage-label"
:aria-valuetext="percentText"
:title="usage"
/>
<p class="large-usage__note">
{{ note }}
</p>
</section>
</template>
<style scoped>
.large-usage {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-14) var(--space-16);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.large-usage__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-12);
}
.large-usage__label,
.large-usage__value {
font-weight: 600;
}
.large-usage__value {
font-variant-numeric: tabular-nums;
}
/* 4px 막대 — 메일함 사용량(MailQuotaMeter)과 같은 모양 */
.large-usage__meter {
appearance: none;
display: block;
width: 100%;
height: var(--space-4);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
overflow: hidden;
}
.large-usage__meter::-webkit-meter-bar {
height: var(--space-4);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
}
.large-usage__meter::-webkit-meter-optimum-value {
border-radius: var(--space-2);
background: var(--color-brand);
}
.large-usage__meter::-webkit-meter-suboptimum-value,
.large-usage__meter::-webkit-meter-even-less-good-value {
border-radius: var(--space-2);
background: var(--color-danger);
}
.large-usage__meter::-moz-meter-bar {
border-radius: var(--space-2);
background: var(--color-brand);
}
.large-usage__meter:-moz-meter-sub-optimum::-moz-meter-bar,
.large-usage__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
background: var(--color-danger);
}
.large-usage__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,234 @@
<script setup lang="ts">
// 공개 내려받기 화면의 파일 한 줄 (디자인 원본 SharePage) — 아이콘 36 · 이름 · "1.2 GB · 보안 검사 · 이상 없음" · [내려받기](채운 단추 44)
// 상태마다 글: 검사 중(단추 aria-disabled — 초점은 받고 누르면 까닭을 읽어 준다) · 위험(빨간 줄, 단추 없음) ·
// 검사하지 못함(갈색 글, 받을 수 있다) · 횟수 초과 · 찾을 수 없음(단추 없음).
// [내려받기]는 <a href> — 링크 주소 복사 같은 브라우저 기본 기능을 지킨다. 누르면 화면이 상태를 한 번 더 묻고 받는다(download).
// 좁은 폭(휴대폰)에서는 단추가 이름 아래 전체 폭이다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { SHARE_PAGE_TEXT } from '@/shared/constants/large-file'
import type { PublicLinkFile } from '@/shared/types/large-file'
import { formatFileSize } from '@/shared/utils/format'
import { isDownloadable } from '@/shared/utils/large-file-status'
interface Props {
file: PublicLinkFile
/** 파일 주소 — 단추의 href */
href: string
/** 상태를 다시 묻는 중 */
busy?: boolean
}
interface Emits {
/** [내려받기] — 화면이 상태를 한 번 더 묻고 받는다 */
(e: 'download', file: PublicLinkFile): void
/** 검사 중이라 받을 수 없는 단추를 눌렀다 — 까닭을 읽어 준다 */
(e: 'blocked', reason: string): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
/** 가운데 버튼(새 탭으로 열기) — 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const metaId = computed(() => `share-file-${props.file.id}`)
const size = computed(() => formatFileSize(props.file.size))
const view = computed<{ meta: string; tone: 'normal' | 'warning' | 'danger' | 'muted'; button: 'ready' | 'blocked' | 'none' }>(
() => {
switch (props.file.status) {
case 'available':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.clean}`, tone: 'normal', button: 'ready' }
case 'unscannable':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.unscannable}`, tone: 'warning', button: 'ready' }
case 'scanning':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.scanning}`, tone: 'normal', button: 'blocked' }
case 'infected':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.infected}`, tone: 'danger', button: 'none' }
case 'limit-reached':
return { meta: SHARE_PAGE_TEXT.limitReached, tone: 'muted', button: 'none' }
// 'missing' — 파일을 찾을 수 없다 (저장소 복원 등 — 드묾)
default:
return { meta: SHARE_PAGE_TEXT.missing, tone: 'muted', button: 'none' }
}
},
)
function onDownload(event: MouseEvent) {
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (!props.busy && isDownloadable(props.file.status)) emit('download', props.file)
}
</script>
<template>
<li
:class="['share-file', `share-file--${view.tone}`]"
:data-share-file="file.id"
>
<div class="share-file__main">
<span
class="share-file__icon"
aria-hidden="true"
>
<AppIcon :name="view.tone === 'danger' ? 'alert-triangle' : 'file'" />
</span>
<div class="share-file__text">
<span class="share-file__name">{{ file.name }}</span>
<span
:id="metaId"
class="share-file__meta"
>{{ view.meta }}</span>
</div>
</div>
<a
v-if="view.button === 'ready'"
class="share-file__download"
:href="href"
:aria-label="SHARE_PAGE_TEXT.downloadLabel(file.name)"
:aria-busy="busy ? 'true' : undefined"
download
rel="noreferrer"
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon
name="download"
:size="15"
:stroke-width="2"
/>
<span>{{ SHARE_PAGE_TEXT.download }}</span>
</a>
<button
v-else-if="view.button === 'blocked'"
type="button"
class="share-file__download"
aria-disabled="true"
:aria-label="SHARE_PAGE_TEXT.downloadLabel(file.name)"
:aria-describedby="metaId"
@click="emit('blocked', view.meta)"
>
<span>{{ SHARE_PAGE_TEXT.download }}</span>
</button>
</li>
</template>
<style scoped>
.share-file {
display: flex;
align-items: center;
gap: var(--space-12);
padding: var(--space-12) var(--space-12) var(--space-12) var(--space-14);
}
.share-file + .share-file {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.share-file__main {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-12);
}
/* 아이콘 36 */
.share-file__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-36);
height: var(--space-36);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
.share-file__text {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.4;
}
.share-file__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.share-file__meta {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* [내려받기] — 채운 단추 44 */
.share-file__download {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
text-decoration: none;
}
.share-file__download:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
color: var(--color-on-brand);
}
/* 검사 중 — 초점은 받되 회색 (누르면 까닭을 읽어 준다) */
.share-file__download[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
.share-file--warning .share-file__meta {
color: var(--color-warning-text);
}
.share-file--danger {
margin: var(--space-4);
border: var(--border-width) solid var(--color-danger-border);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
}
.share-file--danger .share-file__icon {
background: transparent;
color: var(--color-danger);
}
.share-file--danger .share-file__name,
.share-file--danger .share-file__meta {
color: var(--color-danger);
}
/* 좁은 폭(휴대폰) — 단추가 이름 아래 전체 폭 */
@media (max-width: 30rem) {
.share-file {
flex-direction: column;
align-items: stretch;
gap: var(--space-10);
}
.share-file__download {
width: 100%;
}
}
</style>