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.
- package/README.md +6 -6
- package/dist/composables/useMemiBoardPosts.d.ts +8 -0
- package/dist/composables/useMemiBoardSettings.d.ts +5 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +361 -266
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/Attachments.vue +1 -1
- package/dist/runtime/components/Detail.vue +109 -40
- package/dist/runtime/components/Editor.vue +62 -23
- package/dist/runtime/components/List.vue +19 -49
- package/dist/runtime/components/ListDefault.vue +41 -0
- package/dist/runtime/components/ListImage.vue +41 -0
- package/dist/runtime/components/ListVideo.vue +44 -0
- package/dist/runtime/components/Settings.vue +238 -0
- package/dist/types.d.ts +12 -2
- package/dist/utils/postPreview.d.ts +6 -0
- package/docs/firestore.rules.example +13 -5
- package/docs/storage.rules.example +2 -0
- package/package.json +3 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
3
|
-
import
|
|
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<{
|
|
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
|
-
<!--
|
|
151
|
-
<
|
|
152
|
-
class="prose dark:prose-invert max-w-none break-words"
|
|
153
|
-
|
|
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,
|
|
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 ??
|
|
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 (!
|
|
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
|
-
|
|
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 =
|
|
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="
|
|
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
|
|
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 {
|
|
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="
|
|
116
|
-
v-
|
|
117
|
-
:
|
|
118
|
-
:to="postTo
|
|
119
|
-
|
|
120
|
-
|
|
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>
|