generated from ttipo/project-base
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user