memi-board 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -11,6 +11,7 @@ import MemiBoardAttachments from './Attachments.vue'
|
|
|
11
11
|
import MemiBoardCommentForm from './CommentForm.vue'
|
|
12
12
|
import MemiBoardCommentList from './CommentList.vue'
|
|
13
13
|
import MemiBoardLikeButton from './LikeButton.vue'
|
|
14
|
+
import MemiBoardShareButton from './ShareButton.vue'
|
|
14
15
|
import MemiBoardSwipeHint from './SwipeHint.vue'
|
|
15
16
|
import MemiBoardAuthorMenu from './AuthorMenu.vue'
|
|
16
17
|
|
|
@@ -297,12 +298,13 @@ const contentHtml = computed(() => {
|
|
|
297
298
|
</div>
|
|
298
299
|
</div>
|
|
299
300
|
|
|
300
|
-
<div v-if="!isDraft" class="flex justify-center">
|
|
301
|
+
<div v-if="!isDraft" class="flex items-center justify-center gap-2">
|
|
301
302
|
<MemiBoardLikeButton
|
|
302
303
|
:board-id="boardId"
|
|
303
304
|
:post-id="postId"
|
|
304
305
|
:like-count="post.likeCount ?? 0"
|
|
305
306
|
/>
|
|
307
|
+
<MemiBoardShareButton :title="post.title" />
|
|
306
308
|
</div>
|
|
307
309
|
|
|
308
310
|
<nav class="grid grid-cols-3 items-center py-3" aria-label="게시글 이동">
|
|
@@ -40,7 +40,7 @@ const { uploadEditorImage } = useMemiBoardStorage()
|
|
|
40
40
|
const title = ref('')
|
|
41
41
|
/** UEditor content-type=html (shineb 와 동일) */
|
|
42
42
|
const content = ref('')
|
|
43
|
-
const
|
|
43
|
+
const tags = ref<string[]>([])
|
|
44
44
|
const attachments = ref<Attachment[]>([])
|
|
45
45
|
|
|
46
46
|
/** 이미지 리스트뷰 보드 — 제목 없음, 이미지+본문 필수 */
|
|
@@ -569,7 +569,7 @@ async function loadPost(id: string) {
|
|
|
569
569
|
return
|
|
570
570
|
}
|
|
571
571
|
title.value = post.title
|
|
572
|
-
|
|
572
|
+
tags.value = post.tags ?? []
|
|
573
573
|
attachments.value = post.attachments ?? []
|
|
574
574
|
attachmentNamespace.value = id
|
|
575
575
|
// 이미지 보드: 본문 이미지를 갤러리로, 에디터에는 글만
|
|
@@ -665,14 +665,13 @@ async function handleSubmit() {
|
|
|
665
665
|
}
|
|
666
666
|
|
|
667
667
|
submitHint.value = '저장하는 중…'
|
|
668
|
-
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
|
669
668
|
const bodyContent = imageBoard && coverSlots.value.length
|
|
670
669
|
? withCoverImagesInContent(content.value, coverUrls())
|
|
671
670
|
: content.value
|
|
672
671
|
const payload = {
|
|
673
672
|
title: imageBoard ? '' : title.value.trim(),
|
|
674
673
|
content: bodyContent,
|
|
675
|
-
tags,
|
|
674
|
+
tags: tags.value.map(t => t.trim()).filter(Boolean),
|
|
676
675
|
// 이미지 보드: 파일 첨부 없음 (커버는 본문 앞 img 로 저장)
|
|
677
676
|
attachments: imageBoard ? [] : attachments.value,
|
|
678
677
|
}
|
|
@@ -903,9 +902,11 @@ async function handleSubmit() {
|
|
|
903
902
|
</p>
|
|
904
903
|
</div>
|
|
905
904
|
|
|
906
|
-
<
|
|
907
|
-
v-model="
|
|
908
|
-
placeholder="태그
|
|
905
|
+
<UInputTags
|
|
906
|
+
v-model="tags"
|
|
907
|
+
placeholder="태그 입력 후 Enter"
|
|
908
|
+
:delimiter="','"
|
|
909
|
+
add-on-paste
|
|
909
910
|
/>
|
|
910
911
|
|
|
911
912
|
<!-- 이미지 보드: 본문 이미지 업로드만 (파일 첨부 없음) -->
|
|
@@ -25,15 +25,15 @@ function thumb(post: PostModel): string | undefined {
|
|
|
25
25
|
<template>
|
|
26
26
|
<!-- 조밀 = 커뮤니티 게시판 스타일 한 줄 목록. 우측에 작은 사각 썸네일(있을 때만). -->
|
|
27
27
|
<ul class="flex flex-col divide-y divide-default">
|
|
28
|
-
<li v-for="post in posts" :key="post.id" class="flex
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
<li v-for="post in posts" :key="post.id" class="flex items-center gap-2 px-2 py-1.5">
|
|
29
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
30
|
+
<!-- 제목만 클릭 시 글로 이동 — 메타 줄의 시간 팝오버·작성자 메뉴는 별도 인터랙션이라 링크 밖에 둔다 -->
|
|
31
|
+
<component
|
|
32
|
+
:is="postTo(post) ? NuxtLink : 'button'"
|
|
33
|
+
:to="postTo(post)"
|
|
34
|
+
class="group flex min-w-0 w-full items-baseline gap-1.5 rounded-lg text-left transition-colors hover:bg-elevated/60"
|
|
35
|
+
@click="!postTo(post) && emit('select', post)"
|
|
36
|
+
>
|
|
37
37
|
<UBadge
|
|
38
38
|
v-if="post.isPublished === false"
|
|
39
39
|
label="초안"
|
|
@@ -51,67 +51,71 @@ function thumb(post: PostModel): string | undefined {
|
|
|
51
51
|
>
|
|
52
52
|
{{ post.commentCount }}
|
|
53
53
|
</span>
|
|
54
|
-
</
|
|
54
|
+
</component>
|
|
55
55
|
|
|
56
|
-
<div
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<time
|
|
71
|
-
:datetime="post.createdAt?.toDate?.().toISOString()"
|
|
72
|
-
class="cursor-pointer tabular-nums"
|
|
73
|
-
>
|
|
74
|
-
{{ formatRelativeDate(post.createdAt, now) }}
|
|
75
|
-
</time>
|
|
76
|
-
<template #content>
|
|
77
|
-
<p class="whitespace-pre-line px-3 py-2 text-xs">
|
|
78
|
-
{{ formatTimestampDetails(post.createdAt, post.updatedAt).join('\n') }}
|
|
79
|
-
</p>
|
|
80
|
-
</template>
|
|
81
|
-
</UPopover>
|
|
82
|
-
<span class="text-default/40" aria-hidden="true">|</span>
|
|
83
|
-
<span class="font-medium text-toned">
|
|
84
|
-
<MemiBoardAuthorMenu
|
|
85
|
-
:author-uid="post.authorUid"
|
|
86
|
-
:author-name="post.authorName"
|
|
87
|
-
:author-photo="post.authorPhoto"
|
|
88
|
-
:author-posts-to="authorPostsTo"
|
|
89
|
-
:author-profile-to="authorProfileTo"
|
|
90
|
-
:author-comments-to="authorCommentsTo"
|
|
91
|
-
:show-avatar="false"
|
|
92
|
-
/>
|
|
93
|
-
</span>
|
|
94
|
-
<span class="text-default/40" aria-hidden="true">|</span>
|
|
95
|
-
<span
|
|
96
|
-
v-if="post.attachments?.length"
|
|
97
|
-
class="inline-flex items-center gap-0.5 tabular-nums"
|
|
98
|
-
>
|
|
99
|
-
<UIcon name="i-lucide-paperclip" class="size-3 shrink-0" />{{ post.attachments.length }}
|
|
100
|
-
</span>
|
|
101
|
-
<span class="inline-flex items-center gap-0.5 tabular-nums">
|
|
102
|
-
<UIcon name="i-lucide-eye" class="size-3 shrink-0" />{{ post.viewCount ?? 0 }}
|
|
103
|
-
</span>
|
|
104
|
-
<template v-if="(post.likeCount ?? 0) > 0">
|
|
56
|
+
<div class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-none text-muted">
|
|
57
|
+
<UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
|
|
58
|
+
<time
|
|
59
|
+
:datetime="post.createdAt?.toDate?.().toISOString()"
|
|
60
|
+
class="cursor-pointer tabular-nums"
|
|
61
|
+
>
|
|
62
|
+
{{ formatRelativeDate(post.createdAt, now) }}
|
|
63
|
+
</time>
|
|
64
|
+
<template #content>
|
|
65
|
+
<p class="whitespace-pre-line px-3 py-2 text-xs">
|
|
66
|
+
{{ formatTimestampDetails(post.createdAt, post.updatedAt).join('\n') }}
|
|
67
|
+
</p>
|
|
68
|
+
</template>
|
|
69
|
+
</UPopover>
|
|
105
70
|
<span class="text-default/40" aria-hidden="true">|</span>
|
|
106
|
-
<span class="
|
|
107
|
-
|
|
71
|
+
<span class="font-medium text-toned">
|
|
72
|
+
<MemiBoardAuthorMenu
|
|
73
|
+
:author-uid="post.authorUid"
|
|
74
|
+
:author-name="post.authorName"
|
|
75
|
+
:author-photo="post.authorPhoto"
|
|
76
|
+
:author-posts-to="authorPostsTo"
|
|
77
|
+
:author-profile-to="authorProfileTo"
|
|
78
|
+
:author-comments-to="authorCommentsTo"
|
|
79
|
+
:show-avatar="false"
|
|
80
|
+
/>
|
|
108
81
|
</span>
|
|
109
|
-
</template>
|
|
110
|
-
<template v-if="post.category && showCategory">
|
|
111
82
|
<span class="text-default/40" aria-hidden="true">|</span>
|
|
112
|
-
<span
|
|
113
|
-
|
|
83
|
+
<span
|
|
84
|
+
v-if="post.attachments?.length"
|
|
85
|
+
class="inline-flex items-center gap-0.5 tabular-nums"
|
|
86
|
+
>
|
|
87
|
+
<UIcon name="i-lucide-paperclip" class="size-3 shrink-0" />{{ post.attachments.length }}
|
|
88
|
+
</span>
|
|
89
|
+
<span class="inline-flex items-center gap-0.5 tabular-nums">
|
|
90
|
+
<UIcon name="i-lucide-eye" class="size-3 shrink-0" />{{ post.viewCount ?? 0 }}
|
|
91
|
+
</span>
|
|
92
|
+
<template v-if="(post.likeCount ?? 0) > 0">
|
|
93
|
+
<span class="text-default/40" aria-hidden="true">|</span>
|
|
94
|
+
<span class="inline-flex items-center gap-0.5 tabular-nums">
|
|
95
|
+
추천 {{ post.likeCount }}
|
|
96
|
+
</span>
|
|
97
|
+
</template>
|
|
98
|
+
<template v-if="post.category && showCategory">
|
|
99
|
+
<span class="text-default/40" aria-hidden="true">|</span>
|
|
100
|
+
<span class="truncate text-muted">[{{ categoryLabel(post.category) }}]</span>
|
|
101
|
+
</template>
|
|
102
|
+
</div>
|
|
114
103
|
</div>
|
|
104
|
+
|
|
105
|
+
<!-- 썸네일은 제목+메타 줄을 합친 높이만큼 세로로 나란히 — 제목 줄에만 걸치면 이미지 있는 행만 부자연스럽게 길어진다 -->
|
|
106
|
+
<component
|
|
107
|
+
:is="postTo(post) ? NuxtLink : 'button'"
|
|
108
|
+
v-if="thumb(post)"
|
|
109
|
+
:to="postTo(post)"
|
|
110
|
+
class="size-12 shrink-0 overflow-hidden rounded-md bg-elevated"
|
|
111
|
+
@click="!postTo(post) && emit('select', post)"
|
|
112
|
+
>
|
|
113
|
+
<img
|
|
114
|
+
:src="thumb(post)"
|
|
115
|
+
:alt="post.title?.trim() || post.summary || '사진'"
|
|
116
|
+
class="size-full object-cover"
|
|
117
|
+
>
|
|
118
|
+
</component>
|
|
115
119
|
</li>
|
|
116
120
|
</ul>
|
|
117
121
|
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
title?: string | null
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const copied = ref(false)
|
|
9
|
+
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
|
10
|
+
|
|
11
|
+
// 데스크톱 크롬도 navigator.share 자체는 갖고 있어서 존재 여부만으로는 구분이 안 된다.
|
|
12
|
+
// 실제로 모바일 OS 공유시트(카카오톡 등 포함)를 원하는 건 모바일뿐이므로 UA로 구분한다.
|
|
13
|
+
const isMobile = typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
|
|
14
|
+
|
|
15
|
+
async function handleShare() {
|
|
16
|
+
const url = window.location.href
|
|
17
|
+
|
|
18
|
+
if (isMobile && navigator.share) {
|
|
19
|
+
try {
|
|
20
|
+
await navigator.share({ title: props.title ?? undefined, url })
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if ((err as Error)?.name === 'AbortError') return
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 데스크톱, 혹은 모바일 공유시트 실패 — 링크 복사로 대체
|
|
29
|
+
await navigator.clipboard.writeText(url)
|
|
30
|
+
copied.value = true
|
|
31
|
+
if (copiedTimer) clearTimeout(copiedTimer)
|
|
32
|
+
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onBeforeUnmount(() => {
|
|
36
|
+
if (copiedTimer) clearTimeout(copiedTimer)
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<UButton
|
|
42
|
+
:icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'"
|
|
43
|
+
size="sm"
|
|
44
|
+
variant="ghost"
|
|
45
|
+
:color="copied ? 'success' : 'neutral'"
|
|
46
|
+
:label="copied ? '링크 복사됨' : '공유'"
|
|
47
|
+
@click="handleShare"
|
|
48
|
+
/>
|
|
49
|
+
</template>
|