memi-board 0.4.13 → 0.5.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.
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, onMounted, watch } from 'vue'
3
- import type { PostDetail } from 'memi-board'
3
+ import Youtube from '@tiptap/extension-youtube'
4
+ import type { PostDetail, PostModel } from 'memi-board'
4
5
  import { formatDate, renderMarkdownToHtml } from 'memi-board'
5
6
  import { useMemiBoardPosts } from 'memi-board'
6
7
  import { useMemiBoardAuth } from 'memi-board'
@@ -12,9 +13,14 @@ import MemiBoardCommentList from './CommentList.vue'
12
13
 
13
14
  const props = defineProps<{ postId: string }>()
14
15
 
15
- const emit = defineEmits<{ deleted: [], edit: [postId: string] }>()
16
+ const emit = defineEmits<{
17
+ deleted: []
18
+ edit: [postId: string]
19
+ list: []
20
+ navigate: [post: PostModel]
21
+ }>()
16
22
 
17
- const { getPost, deletePost } = useMemiBoardPosts()
23
+ const { getPost, getAdjacentPosts, deletePost } = useMemiBoardPosts()
18
24
  const { canEdit, canDelete } = useMemiBoardAuth()
19
25
  const { categoryLabel } = useMemiBoardSettings()
20
26
  // post.commentCount는 상세 진입 시점의 스냅샷이라 새 댓글이 즉시 반영되지 않는다 — 실시간 댓글 목록 길이로 표시한다.
@@ -24,6 +30,16 @@ const post = ref<PostDetail | null>(null)
24
30
  const loading = ref(true)
25
31
  const deleting = ref(false)
26
32
  const notFound = ref(false)
33
+ const previousPost = ref<PostModel | null>(null)
34
+ const nextPost = ref<PostModel | null>(null)
35
+ const viewerExtensions = [
36
+ Youtube.configure({
37
+ nocookie: true,
38
+ controls: true,
39
+ width: 640,
40
+ height: 360,
41
+ }),
42
+ ]
27
43
 
28
44
  async function load() {
29
45
  loading.value = true
@@ -33,6 +49,15 @@ async function load() {
33
49
  notFound.value = true
34
50
  }
35
51
  post.value = result
52
+ if (result) {
53
+ const adjacent = await getAdjacentPosts(result)
54
+ previousPost.value = adjacent.previous
55
+ nextPost.value = adjacent.next
56
+ }
57
+ else {
58
+ previousPost.value = null
59
+ nextPost.value = null
60
+ }
36
61
  loading.value = false
37
62
  }
38
63
 
@@ -97,39 +122,6 @@ const contentHtml = computed(() => {
97
122
  {{ post.title }}
98
123
  </h1>
99
124
  </div>
100
- <div
101
- v-if="canEdit(post) || canDelete(post)"
102
- class="flex gap-2 shrink-0"
103
- >
104
- <UButton
105
- v-if="canEdit(post)"
106
- icon="i-lucide-pencil"
107
- size="sm"
108
- variant="outline"
109
- color="neutral"
110
- label="수정"
111
- @click="emit('edit', postId)"
112
- />
113
- <UButton
114
- v-if="canDelete(post)"
115
- icon="i-lucide-trash-2"
116
- size="sm"
117
- variant="outline"
118
- color="error"
119
- label="삭제"
120
- :loading="deleting"
121
- @click="handleDelete"
122
- />
123
- </div>
124
- </div>
125
- <div class="flex items-center gap-3 text-sm text-muted">
126
- <UAvatar
127
- :src="post.authorPhoto ?? undefined"
128
- :alt="post.authorName ?? '익명'"
129
- size="xs"
130
- />
131
- <span>{{ post.authorName ?? '익명' }}</span>
132
- <span>{{ formatDate(post.createdAt) }}</span>
133
125
  </div>
134
126
  <div
135
127
  v-if="post.tags?.length"
@@ -147,10 +139,14 @@ const contentHtml = computed(() => {
147
139
  </div>
148
140
  </header>
149
141
 
150
- <!-- 마크다운 본문 (정적 렌더 — 상세에 UEditor 쓰면 목록 이동 시 TipTap plugin 충돌) -->
151
- <div
152
- class="prose dark:prose-invert max-w-none break-words"
153
- v-html="contentHtml"
142
+ <!-- 작성 화면과 동일한 TipTap 스타일을 사용하는 읽기 전용 뷰어 -->
143
+ <UEditor
144
+ class="board-content prose dark:prose-invert max-w-none break-words"
145
+ :model-value="contentHtml"
146
+ content-type="html"
147
+ :editable="false"
148
+ :extensions="viewerExtensions"
149
+ :starter-kit="{ link: { openOnClick: true } }"
154
150
  />
155
151
 
156
152
  <MemiBoardAttachments
@@ -159,6 +155,68 @@ const contentHtml = computed(() => {
159
155
  :post-id="postId"
160
156
  />
161
157
 
158
+ <div class="flex flex-col items-end gap-2">
159
+ <div class="flex items-center justify-end gap-3 text-sm text-muted">
160
+ <UAvatar
161
+ :src="post.authorPhoto ?? undefined"
162
+ :alt="post.authorName ?? '익명'"
163
+ size="xs"
164
+ />
165
+ <span>{{ post.authorName ?? '익명' }}</span>
166
+ <span>{{ formatDate(post.createdAt) }}</span>
167
+ </div>
168
+ <div v-if="canEdit(post) || canDelete(post)" class="flex gap-2">
169
+ <UButton
170
+ v-if="canEdit(post)"
171
+ icon="i-lucide-pencil"
172
+ size="sm"
173
+ variant="ghost"
174
+ color="neutral"
175
+ label="수정"
176
+ @click="emit('edit', postId)"
177
+ />
178
+ <UButton
179
+ v-if="canDelete(post)"
180
+ icon="i-lucide-trash-2"
181
+ size="sm"
182
+ variant="ghost"
183
+ color="error"
184
+ label="삭제"
185
+ :loading="deleting"
186
+ @click="handleDelete"
187
+ />
188
+ </div>
189
+ </div>
190
+
191
+ <nav class="grid grid-cols-3 items-center border-y border-default py-3" aria-label="게시글 이동">
192
+ <UButton
193
+ variant="ghost"
194
+ color="neutral"
195
+ icon="i-lucide-chevron-left"
196
+ label="이전"
197
+ class="justify-self-start"
198
+ :disabled="!previousPost?.id"
199
+ @click="previousPost?.id && emit('navigate', previousPost)"
200
+ />
201
+ <UButton
202
+ variant="ghost"
203
+ color="neutral"
204
+ icon="i-lucide-list"
205
+ label="목록"
206
+ class="justify-self-center"
207
+ @click="emit('list')"
208
+ />
209
+ <UButton
210
+ variant="ghost"
211
+ color="neutral"
212
+ trailing-icon="i-lucide-chevron-right"
213
+ label="다음"
214
+ class="justify-self-end"
215
+ :disabled="!nextPost?.id"
216
+ @click="nextPost?.id && emit('navigate', nextPost)"
217
+ />
218
+ </nav>
219
+
162
220
  <section class="flex flex-col gap-4 border-t border-default pt-4">
163
221
  <h2 class="text-sm font-medium text-muted">
164
222
  댓글 {{ comments.length }}
@@ -168,3 +226,14 @@ const contentHtml = computed(() => {
168
226
  </section>
169
227
  </div>
170
228
  </template>
229
+
230
+ <style scoped>
231
+ .board-content :deep(iframe[src*="youtube.com"], iframe[src*="youtube-nocookie.com"], iframe[src*="youtu.be"]) {
232
+ width: 100%;
233
+ max-width: 640px;
234
+ aspect-ratio: 16 / 9;
235
+ height: auto;
236
+ border: 0;
237
+ border-radius: 0.75rem;
238
+ }
239
+ </style>
@@ -4,10 +4,10 @@
4
4
  * @see https://ui.nuxt.com/docs/components/editor
5
5
  * @see shineb app/components/PostEditor.vue
6
6
  *
7
- * 커스텀은 handlers.image(파일 업로드) + DOM paste/drop 만.
8
- * TipTap Extension / PluginKey 는 쓰지 않음 (유지보수·충돌 회피).
7
+ * 커스텀은 handlers.image(파일 업로드), YouTube 임베드 + DOM paste/drop.
9
8
  */
10
9
  import { ref, computed, watch } from 'vue'
10
+ import Youtube from '@tiptap/extension-youtube'
11
11
  import type { Attachment, EditorImageEntry } from 'memi-board'
12
12
  import {
13
13
  useMemiBoardAuth,
@@ -27,9 +27,9 @@ const props = defineProps<{
27
27
  const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
28
28
 
29
29
  const { user, isSignedIn, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
30
- const { getPost, createPost, updatePost } = useMemiBoardPosts()
30
+ const { createPostId, getPost, createPost, updatePost } = useMemiBoardPosts()
31
31
  const { checkText } = useMemiBoardModeration()
32
- const { categories, categoryLabel, ensureSettings, addCategory } = useMemiBoardSettings()
32
+ const { categories, ensureSettings, addCategory } = useMemiBoardSettings()
33
33
  const { uploadEditorImage } = useMemiBoardStorage()
34
34
 
35
35
  const title = ref('')
@@ -44,7 +44,6 @@ const categoryItems = computed(() =>
44
44
  )
45
45
  const effectiveCategory = computed(() => props.fixedCategory || category.value)
46
46
  const isEdit = computed(() => Boolean(props.postId))
47
- const categoryLocked = computed(() => Boolean(props.fixedCategory))
48
47
 
49
48
  const loading = ref(false)
50
49
  const saving = ref(false)
@@ -57,9 +56,17 @@ const showAddCategory = ref(false)
57
56
  const newCategoryLabel = ref('')
58
57
  const addingCategory = ref(false)
59
58
 
60
- const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
59
+ const attachmentNamespace = ref(props.postId ?? createPostId())
61
60
  const uploadedEditorImages = ref<EditorImageEntry[]>([])
62
61
  const editorRef = ref<{ editor?: unknown } | null>(null)
62
+ const editorExtensions = [
63
+ Youtube.configure({
64
+ nocookie: true,
65
+ controls: true,
66
+ width: 640,
67
+ height: 360,
68
+ }),
69
+ ]
63
70
 
64
71
  function imageNamespace(): string {
65
72
  return props.postId || attachmentNamespace.value
@@ -103,6 +110,28 @@ function pickAndUploadImage(editor: any) {
103
110
  input.click()
104
111
  }
105
112
 
113
+ function youtubeUrl(value: string | null | undefined): string | null {
114
+ const raw = value?.trim()
115
+ if (!raw) return null
116
+ try {
117
+ const url = new URL(raw)
118
+ const host = url.hostname.toLowerCase().replace(/^www\./, '')
119
+ if (host === 'youtu.be' || host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') {
120
+ return url.toString()
121
+ }
122
+ }
123
+ catch {
124
+ // URL 형식이 아니면 YouTube 임베드로 처리하지 않는다.
125
+ }
126
+ return null
127
+ }
128
+
129
+ function insertYoutube(editor: any, value?: string | null): boolean {
130
+ const src = youtubeUrl(value ?? window.prompt('YouTube 링크를 입력하세요.'))
131
+ if (!src) return false
132
+ return editor.chain().focus().setYoutubeVideo({ src }).run()
133
+ }
134
+
106
135
  /** 공식 handlers API — 기본 image(URL prompt) 를 파일 업로드로 교체 (shineb) */
107
136
  const handlers = {
108
137
  image: {
@@ -115,6 +144,16 @@ const handlers = {
115
144
  return editor.chain()
116
145
  },
117
146
  },
147
+ youtube: {
148
+ canExecute: (editor: any) => editor.can().setYoutubeVideo({ src: 'https://youtu.be/dQw4w9WgXcQ' }),
149
+ isActive: (editor: any) => editor.isActive('youtube'),
150
+ isDisabled: (editor: any) =>
151
+ !editor.extensionManager?.extensions?.some((ext: any) => ext.name === 'youtube'),
152
+ execute: (editor: any) => {
153
+ insertYoutube(editor)
154
+ return editor.chain()
155
+ },
156
+ },
118
157
  }
119
158
 
120
159
  const toolbarItems = [
@@ -139,6 +178,7 @@ const toolbarItems = [
139
178
  [
140
179
  { kind: 'link', icon: 'i-lucide-link' },
141
180
  { kind: 'image', icon: 'i-lucide-image' },
181
+ { kind: 'youtube', icon: 'i-lucide-youtube', label: 'YouTube' },
142
182
  ],
143
183
  [
144
184
  { kind: 'horizontalRule', icon: 'i-lucide-minus' },
@@ -166,12 +206,20 @@ function resolveEditor(): any | null {
166
206
  function onEditorRootPaste(e: ClipboardEvent) {
167
207
  const items = Array.from(e.clipboardData?.items ?? [])
168
208
  const imageItem = items.find(i => i.type.startsWith('image/'))
169
- if (!imageItem) return
170
- const file = imageItem.getAsFile()
171
209
  const editor = resolveEditor()
172
- if (!file || !editor) return
210
+ if (!editor) return
211
+ if (imageItem) {
212
+ const file = imageItem.getAsFile()
213
+ if (!file) return
214
+ e.preventDefault()
215
+ void uploadAndSetImage(editor, file)
216
+ return
217
+ }
218
+
219
+ const src = youtubeUrl(e.clipboardData?.getData('text/plain'))
220
+ if (!src) return
173
221
  e.preventDefault()
174
- void uploadAndSetImage(editor, file)
222
+ insertYoutube(editor, src)
175
223
  }
176
224
 
177
225
  function onEditorRootDrop(e: DragEvent) {
@@ -223,7 +271,7 @@ watch(
223
271
  if (id) void loadPost(id)
224
272
  else {
225
273
  loading.value = false
226
- attachmentNamespace.value = `new-${Date.now()}`
274
+ attachmentNamespace.value = createPostId()
227
275
  if (props.fixedCategory) category.value = props.fixedCategory
228
276
  void ensureSettings().catch(() => {})
229
277
  }
@@ -326,6 +374,7 @@ async function handleSubmit() {
326
374
  else {
327
375
  const id = await createPost({
328
376
  ...payload,
377
+ postId: attachmentNamespace.value,
329
378
  authorUid: user.value.uid,
330
379
  authorName: user.value.displayName,
331
380
  authorPhoto: user.value.photoURL,
@@ -358,18 +407,7 @@ async function handleSubmit() {
358
407
  @submit.prevent="handleSubmit"
359
408
  >
360
409
  <div
361
- v-if="categoryLocked"
362
- class="flex items-center gap-2 text-sm"
363
- >
364
- <span class="text-muted">카테고리</span>
365
- <UBadge
366
- :label="categoryLabel(fixedCategory) || fixedCategory"
367
- variant="subtle"
368
- />
369
- </div>
370
-
371
- <div
372
- v-else
410
+ v-if="!fixedCategory"
373
411
  class="flex flex-col gap-2"
374
412
  >
375
413
  <div class="flex items-center gap-2 flex-wrap">
@@ -445,6 +483,7 @@ async function handleSubmit() {
445
483
  ref="editorRef"
446
484
  v-model="content"
447
485
  content-type="html"
486
+ :extensions="editorExtensions"
448
487
  :handlers="handlers"
449
488
  placeholder="본문을 입력하세요…"
450
489
  :ui="{ content: 'min-h-64 p-4' }"
@@ -1,11 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue'
3
- import { RouterLink } from 'vue-router'
2
+ import { computed, ref, watch } from 'vue'
4
3
  import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
5
4
  import { useMemiBoardPosts } from 'memi-board'
6
5
  import { useMemiBoardSettings } from 'memi-board'
7
6
  import type { PostModel } from 'memi-board'
8
- import { formatDate } from 'memi-board'
7
+ import MemiBoardListDefault from './ListDefault.vue'
8
+ import MemiBoardListImage from './ListImage.vue'
9
+ import MemiBoardListVideo from './ListVideo.vue'
9
10
 
10
11
  const props = withDefaults(defineProps<{
11
12
  pageSize?: number
@@ -31,7 +32,7 @@ const props = withDefaults(defineProps<{
31
32
  const emit = defineEmits<{ select: [post: PostModel] }>()
32
33
 
33
34
  const { getPosts } = useMemiBoardPosts()
34
- const { categoryLabel } = useMemiBoardSettings()
35
+ const { categories } = useMemiBoardSettings()
35
36
 
36
37
  const posts = ref<PostModel[]>([])
37
38
  const cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)
@@ -39,6 +40,14 @@ const hasMore = ref(false)
39
40
  const loading = ref(false)
40
41
  const initialLoading = ref(true)
41
42
  const loadError = ref('')
43
+ const listView = computed(() =>
44
+ categories.value.find(item => item.id === props.category)?.listView ?? 'default',
45
+ )
46
+ const listComponent = computed(() => ({
47
+ default: MemiBoardListDefault,
48
+ image: MemiBoardListImage,
49
+ video: MemiBoardListVideo,
50
+ })[listView.value])
42
51
 
43
52
  function postTo(post: PostModel): string | undefined {
44
53
  if (props.getPostLink) return props.getPostLink(post)
@@ -82,9 +91,6 @@ watch(
82
91
  { immediate: true },
83
92
  )
84
93
 
85
- function handleClick(post: PostModel) {
86
- if (!postTo(post)) emit('select', post)
87
- }
88
94
  </script>
89
95
 
90
96
  <template>
@@ -112,48 +118,12 @@ function handleClick(post: PostModel) {
112
118
  </p>
113
119
 
114
120
  <component
115
- :is="postTo(post) ? RouterLink : 'button'"
116
- v-for="post in posts"
117
- :key="post.id"
118
- :to="postTo(post)"
119
- class="text-left"
120
- @click="handleClick(post)"
121
- >
122
- <UCard class="hover:bg-elevated/50 transition-colors">
123
- <div class="flex items-center justify-between gap-4">
124
- <div class="flex items-center gap-2 min-w-0">
125
- <UBadge
126
- v-if="post.category"
127
- :label="categoryLabel(post.category)"
128
- variant="subtle"
129
- size="sm"
130
- />
131
- <h3 class="font-medium truncate">
132
- {{ post.title }}
133
- </h3>
134
- </div>
135
- <span class="text-xs text-muted shrink-0">{{ formatDate(post.createdAt) }}</span>
136
- </div>
137
- <div class="flex items-center gap-3 text-xs text-muted mt-2">
138
- <span>{{ post.authorName ?? '익명' }}</span>
139
- <span class="flex items-center gap-1">
140
- <UIcon
141
- name="i-lucide-message-circle"
142
- class="size-3"
143
- />{{ post.commentCount }}
144
- </span>
145
- <span
146
- v-if="post.attachments?.length"
147
- class="flex items-center gap-1"
148
- >
149
- <UIcon
150
- name="i-lucide-paperclip"
151
- class="size-3"
152
- />{{ post.attachments.length }}
153
- </span>
154
- </div>
155
- </UCard>
156
- </component>
121
+ :is="listComponent"
122
+ v-else-if="posts.length"
123
+ :posts="posts"
124
+ :post-to="postTo"
125
+ @select="emit('select', $event)"
126
+ />
157
127
 
158
128
  <UButton
159
129
  v-if="hasMore"
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { RouterLink } from 'vue-router'
3
+ import type { PostModel } from 'memi-board'
4
+ import { formatDate, useMemiBoardSettings } from 'memi-board'
5
+
6
+ defineProps<{
7
+ posts: PostModel[]
8
+ postTo: (post: PostModel) => string | undefined
9
+ }>()
10
+ const emit = defineEmits<{ select: [post: PostModel] }>()
11
+ const { categoryLabel } = useMemiBoardSettings()
12
+ </script>
13
+
14
+ <template>
15
+ <div class="flex flex-col gap-2">
16
+ <component
17
+ :is="postTo(post) ? RouterLink : 'button'"
18
+ v-for="post in posts"
19
+ :key="post.id"
20
+ :to="postTo(post)"
21
+ class="text-left"
22
+ @click="!postTo(post) && emit('select', post)"
23
+ >
24
+ <UCard class="hover:bg-elevated/50 transition-colors">
25
+ <div class="flex items-center justify-between gap-4">
26
+ <div class="flex items-center gap-2 min-w-0">
27
+ <UBadge v-if="post.category" :label="categoryLabel(post.category)" variant="subtle" size="sm" />
28
+ <h3 class="font-medium truncate">{{ post.title }}</h3>
29
+ </div>
30
+ <span class="text-xs text-muted shrink-0">{{ formatDate(post.createdAt) }}</span>
31
+ </div>
32
+ <p v-if="post.summary" class="mt-2 line-clamp-2 text-sm text-muted">{{ post.summary }}</p>
33
+ <div class="flex items-center gap-3 text-xs text-muted mt-2">
34
+ <span>{{ post.authorName ?? '익명' }}</span>
35
+ <span class="flex items-center gap-1"><UIcon name="i-lucide-message-circle" class="size-3" />{{ post.commentCount }}</span>
36
+ <span v-if="post.attachments?.length" class="flex items-center gap-1"><UIcon name="i-lucide-paperclip" class="size-3" />{{ post.attachments.length }}</span>
37
+ </div>
38
+ </UCard>
39
+ </component>
40
+ </div>
41
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { RouterLink } from 'vue-router'
3
+ import type { PostModel } from 'memi-board'
4
+ import { formatDate } from 'memi-board'
5
+
6
+ defineProps<{
7
+ posts: PostModel[]
8
+ postTo: (post: PostModel) => string | undefined
9
+ }>()
10
+ const emit = defineEmits<{ select: [post: PostModel] }>()
11
+
12
+ function image(post: PostModel): string | undefined {
13
+ return post.previewImage || post.attachments?.find(item => item.type.startsWith('image/'))?.url
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
19
+ <component
20
+ :is="postTo(post) ? RouterLink : 'button'"
21
+ v-for="post in posts"
22
+ :key="post.id"
23
+ :to="postTo(post)"
24
+ class="overflow-hidden rounded-xl border border-default bg-default text-left transition hover:bg-elevated/50"
25
+ @click="!postTo(post) && emit('select', post)"
26
+ >
27
+ <div class="aspect-[4/3] overflow-hidden bg-elevated">
28
+ <img v-if="image(post)" :src="image(post)" :alt="post.title" class="size-full object-cover transition duration-300 hover:scale-105">
29
+ <div v-else class="flex size-full items-center justify-center text-muted"><UIcon name="i-lucide-image" class="size-10" /></div>
30
+ </div>
31
+ <div class="p-4">
32
+ <h3 class="font-medium line-clamp-2">{{ post.title }}</h3>
33
+ <p v-if="post.summary" class="mt-2 line-clamp-2 text-sm text-muted">{{ post.summary }}</p>
34
+ <div class="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
35
+ <span class="truncate">{{ post.authorName ?? '익명' }}</span>
36
+ <span class="shrink-0">{{ formatDate(post.createdAt) }}</span>
37
+ </div>
38
+ </div>
39
+ </component>
40
+ </div>
41
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { RouterLink } from 'vue-router'
3
+ import type { PostModel } from 'memi-board'
4
+ import { formatDate } from 'memi-board'
5
+
6
+ defineProps<{
7
+ posts: PostModel[]
8
+ postTo: (post: PostModel) => string | undefined
9
+ }>()
10
+ const emit = defineEmits<{ select: [post: PostModel] }>()
11
+
12
+ function image(post: PostModel): string | undefined {
13
+ return post.previewImage || post.attachments?.find(item => item.type.startsWith('image/'))?.url
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
19
+ <component
20
+ :is="postTo(post) ? RouterLink : 'button'"
21
+ v-for="post in posts"
22
+ :key="post.id"
23
+ :to="postTo(post)"
24
+ class="overflow-hidden rounded-xl border border-default bg-default text-left transition hover:bg-elevated/50"
25
+ @click="!postTo(post) && emit('select', post)"
26
+ >
27
+ <div class="relative aspect-video overflow-hidden bg-elevated">
28
+ <img v-if="image(post)" :src="image(post)" :alt="post.title" class="size-full object-cover">
29
+ <div v-else class="flex size-full items-center justify-center text-muted"><UIcon name="i-lucide-video" class="size-10" /></div>
30
+ <span class="absolute inset-0 flex items-center justify-center bg-black/10">
31
+ <span class="flex size-12 items-center justify-center rounded-full bg-black/70 text-white shadow"><UIcon name="i-lucide-play" class="ml-0.5 size-6 fill-current" /></span>
32
+ </span>
33
+ </div>
34
+ <div class="p-4">
35
+ <h3 class="font-medium line-clamp-2">{{ post.title }}</h3>
36
+ <p v-if="post.summary" class="mt-2 line-clamp-2 text-sm text-muted">{{ post.summary }}</p>
37
+ <div class="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
38
+ <span class="truncate">{{ post.authorName ?? '익명' }}</span>
39
+ <span class="shrink-0">{{ formatDate(post.createdAt) }}</span>
40
+ </div>
41
+ </div>
42
+ </component>
43
+ </div>
44
+ </template>