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 tagsInput = ref('')
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
- tagsInput.value = (post.tags ?? []).join(', ')
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
- <UInput
907
- v-model="tagsInput"
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 flex-col gap-0.5 px-2 py-1.5">
29
- <!-- 제목+썸네일만 클릭 시 글로 이동 — 메타 줄의 시간 팝오버·작성자 메뉴는 별도 인터랙션이라 링크 밖에 둔다 -->
30
- <component
31
- :is="postTo(post) ? NuxtLink : 'button'"
32
- :to="postTo(post)"
33
- class="group relative flex w-full items-center gap-2 rounded-lg text-left transition-colors hover:bg-elevated/60"
34
- @click="!postTo(post) && emit('select', post)"
35
- >
36
- <div class="flex min-w-0 flex-1 items-baseline gap-1.5">
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
- </div>
54
+ </component>
55
55
 
56
- <div
57
- v-if="thumb(post)"
58
- class="size-12 shrink-0 overflow-hidden rounded-md bg-elevated"
59
- >
60
- <img
61
- :src="thumb(post)"
62
- :alt="post.title?.trim() || post.summary || '사진'"
63
- class="size-full object-cover"
64
- >
65
- </div>
66
- </component>
67
-
68
- <div class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-none text-muted">
69
- <UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
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="inline-flex items-center gap-0.5 tabular-nums">
107
- 추천 {{ post.likeCount }}
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 class="truncate text-muted">[{{ categoryLabel(post.category) }}]</span>
113
- </template>
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt 4 + Nuxt UI + VueFire/Firebase 전용 게시판 모듈",
6
6
  "license": "MIT",