memi-board 0.3.1
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/LICENSE +21 -0
- package/README.md +223 -0
- package/dist/components/Attachments.vue.d.ts +17 -0
- package/dist/components/CommentForm.vue.d.ts +5 -0
- package/dist/components/CommentList.vue.d.ts +5 -0
- package/dist/components/Detail.vue.d.ts +11 -0
- package/dist/components/Editor.vue.d.ts +12 -0
- package/dist/components/List.vue.d.ts +14 -0
- package/dist/components/SignIn.vue.d.ts +2 -0
- package/dist/components/VersionHistory.vue.d.ts +2 -0
- package/dist/composables/useMemiBoardAuth.d.ts +24 -0
- package/dist/composables/useMemiBoardComments.d.ts +14 -0
- package/dist/composables/useMemiBoardModeration.d.ts +12 -0
- package/dist/composables/useMemiBoardPosts.d.ts +35 -0
- package/dist/composables/useMemiBoardSettings.d.ts +12 -0
- package/dist/composables/useMemiBoardStorage.d.ts +8 -0
- package/dist/config.d.ts +21 -0
- package/dist/data/versionHistory.d.ts +11 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1121 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/utils/formatDate.d.ts +2 -0
- package/dist/utils/moderation-prompt.d.ts +9 -0
- package/dist/utils/slugify.d.ts +7 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fkkmemi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# memi-board
|
|
2
|
+
|
|
3
|
+
Nuxt 4 + Nuxt UI 프로젝트에 Firebase 기반 게시판을 바로 붙이는 패키지. 게시글 CRUD, 첨부파일(Storage), 댓글, 권한(작성자 본인/관리자), Firebase AI Logic(Gemini) 기반 AI 검열을 제공한다.
|
|
4
|
+
|
|
5
|
+
클라이언트 전용이다 — 서버나 Firebase Admin SDK가 필요 없다. 소비 프로젝트가 자신의 Firebase 프로젝트 설정만 넘기면 동작한다.
|
|
6
|
+
|
|
7
|
+
**Nuxt 모듈이 아니라 순수 Vue 3 컴포넌트/composable 패키지다.** `nuxt.config`를 건드리거나 `nuxt-vuefire`를 대신 설치해주지 않는다 — 호스트 프로젝트가 이미 자기 앱을 위해 갖추고 있을 Firebase/nuxt-vuefire 설정을 그대로 재사용하고, memi-board는 그 위에 컴포넌트와 composable만 얹는다. 여러 프로젝트에서 같은 게시판을 재사용하는 게 1차 목적이라, "설정 지점 하나(호스트 프로젝트)"를 유지하는 쪽을 우선했다.
|
|
8
|
+
|
|
9
|
+
## 설치
|
|
10
|
+
|
|
11
|
+
**사전 준비물**: Node.js + pnpm, Nuxt 4+ / `@nuxt/ui` 4+, Firestore·Storage·Authentication을 켜둔 Firebase 프로젝트, Firebase CLI(`firebase login` 완료, `firebase.json`/`.firebaserc` 연결). 아직 하나라도 없다면 → [docs/firebase-setup.md](docs/firebase-setup.md)에서 처음부터 순서대로 설명한다.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add memi-board @nuxt/ui nuxt-vuefire vuefire firebase
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`vuefire`/`firebase`/`vue-router`는 `peerDependencies`다 — 반드시 프로젝트에 직접 설치해야 한다(`vue-router`는 Nuxt가 내부적으로 이미 갖고 있어 보통 별도 설치가 필요 없다). memi-board가 자체적으로 이 패키지들을 번들하지 않는 이유는, 그러면 호스트 프로젝트가 설치한 버전과 memi-board 내부 버전이 **서로 다른 모듈 인스턴스**가 돼서 `useCurrentUser()`가 항상 `undefined`를 반환하거나 "VueFireAuth module was added" 에러가 나기 때문이다(vuefire의 인증 상태는 모듈 인스턴스별 내부 상태로 관리됨). 직접 설치하면 하나로 합쳐진다. `nuxt-vuefire`는 memi-board의 peer는 아니지만(memi-board는 vuefire만 직접 쓴다), Nuxt에서 Firebase를 연동하려면 결국 필요하다.
|
|
18
|
+
|
|
19
|
+
### 1. 호스트가 nuxt-vuefire를 직접 설정한다
|
|
20
|
+
|
|
21
|
+
`nuxt.config.ts`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
export default defineNuxtConfig({
|
|
25
|
+
modules: ['@nuxt/ui', 'nuxt-vuefire'],
|
|
26
|
+
vuefire: {
|
|
27
|
+
config: {
|
|
28
|
+
apiKey: '...',
|
|
29
|
+
authDomain: '...',
|
|
30
|
+
projectId: '...',
|
|
31
|
+
storageBucket: '...',
|
|
32
|
+
messagingSenderId: '...',
|
|
33
|
+
appId: '...',
|
|
34
|
+
},
|
|
35
|
+
auth: { enabled: true },
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`firebaseConfig` 값은 Firebase 콘솔 → 프로젝트 설정 → 일반 → "내 앱"에서 복사한다 (자세한 위치는 [docs/firebase-setup.md](docs/firebase-setup.md#3-firebaseconfig-값-가져오기) 참고). 이미 다른 기능(예: 매장 운영자 로그인 등) 때문에 nuxt-vuefire를 쓰고 있는 프로젝트라면 이 단계는 그대로 건너뛴다 — memi-board는 그 설정을 그대로 재사용한다.
|
|
41
|
+
|
|
42
|
+
### 2. memi-board를 설정한다
|
|
43
|
+
|
|
44
|
+
플러그인 파일 하나(예: `app/plugins/memi-board.ts`)에서 앱 부팅 시 한 번 호출한다:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { configureMemiBoard } from 'memi-board'
|
|
48
|
+
|
|
49
|
+
export default defineNuxtPlugin(() => {
|
|
50
|
+
configureMemiBoard({
|
|
51
|
+
collectionPrefix: 'board', // Firestore 컬렉션 접두사 (boardPosts, boardUsers, boardSettings)
|
|
52
|
+
auth: { providers: ['google', 'apple'] }, // 'emailPassword'도 선택 가능
|
|
53
|
+
moderation: { enabled: true, onError: 'allow' },
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
값을 넘기지 않으면 `collectionPrefix: 'memiBoard'`, `auth.providers: ['google', 'apple']`, `moderation.enabled: true`가 기본값이다.
|
|
59
|
+
|
|
60
|
+
### 3. Tailwind가 memi-board 컴포넌트를 스캔하게 한다
|
|
61
|
+
|
|
62
|
+
Tailwind v4는 기본적으로 `node_modules`를 스캔 대상에서 제외한다 — memi-board의 컴포넌트가 쓰는 유틸리티 클래스(`flex`, `gap-2` 등)가 호스트의 최종 CSS에 아예 생성되지 않아 **레이아웃이 깨진 채로(스타일 없이) 렌더링**된다. 호스트의 CSS 진입점에 `@source`로 memi-board의 빌드 결과물을 명시적으로 추가해야 한다:
|
|
63
|
+
|
|
64
|
+
```css
|
|
65
|
+
/* app/assets/css/main.css */
|
|
66
|
+
@import "tailwindcss";
|
|
67
|
+
@import "@nuxt/ui";
|
|
68
|
+
@source "../../../node_modules/memi-board/dist";
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
프로젝트 구조에 따라 `node_modules`까지의 상대 경로는 조정한다(CSS 파일 기준 상대 경로).
|
|
72
|
+
|
|
73
|
+
> ⚠️ **설치는 여기서 끝이 아니다.** 이 패키지는 서버가 없어서 Firestore/Storage Security Rules를 직접 배포해야 글쓰기·댓글이 동작한다 — 아래 [Security Model](#security-model--반드시-읽어야-하는-부분) 섹션을 반드시 따라 한다. 건너뛰면 글쓰기 버튼을 눌렀을 때 `permission-denied`가 난다.
|
|
74
|
+
|
|
75
|
+
## 사용법
|
|
76
|
+
|
|
77
|
+
컴포넌트를 원하는 페이지에 직접 배치한다 — 라우팅은 항상 호스트 프로젝트가 소유한다.
|
|
78
|
+
|
|
79
|
+
```vue
|
|
80
|
+
<!-- app/pages/board/index.vue -->
|
|
81
|
+
<script setup lang="ts">
|
|
82
|
+
import { MemiBoardList, MemiBoardSignIn, useMemiBoardAuth } from 'memi-board'
|
|
83
|
+
const { isSignedIn } = useMemiBoardAuth()
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<div>
|
|
88
|
+
<UButton v-if="isSignedIn" to="/board/new" label="새 글쓰기" />
|
|
89
|
+
<MemiBoardList link-base="/board" />
|
|
90
|
+
<MemiBoardSignIn v-if="!isSignedIn" />
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```vue
|
|
96
|
+
<!-- app/pages/board/[id].vue -->
|
|
97
|
+
<script setup lang="ts">
|
|
98
|
+
import { MemiBoardDetail } from 'memi-board'
|
|
99
|
+
const route = useRoute()
|
|
100
|
+
const router = useRouter()
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<template>
|
|
104
|
+
<MemiBoardDetail
|
|
105
|
+
:post-id="route.params.id as string"
|
|
106
|
+
@edit="router.push(`/board/${route.params.id}/edit`)"
|
|
107
|
+
@deleted="router.push('/board')"
|
|
108
|
+
/>
|
|
109
|
+
</template>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
글쓰기(`/board/new`)·수정(`/board/[id]/edit`) 라우트는 로그인 여부를 호스트가 직접 가드한다 — memi-board는 라우트 미들웨어를 제공하지 않는다(라우팅은 호스트 관심사):
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// app/middleware/board-auth.ts
|
|
116
|
+
import { getCurrentUser } from 'vuefire'
|
|
117
|
+
|
|
118
|
+
export default defineNuxtRouteMiddleware(async () => {
|
|
119
|
+
const user = await getCurrentUser()
|
|
120
|
+
if (!user) return navigateTo('/board')
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```vue
|
|
125
|
+
<!-- app/pages/board/new.vue -->
|
|
126
|
+
<script setup lang="ts">
|
|
127
|
+
import { MemiBoardEditor } from 'memi-board'
|
|
128
|
+
definePageMeta({ middleware: 'board-auth' })
|
|
129
|
+
const router = useRouter()
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<template>
|
|
133
|
+
<MemiBoardEditor @saved="(id) => router.push(`/board/${id}`)" @cancel="router.push('/board')" />
|
|
134
|
+
</template>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`playground/app/pages/board/`에 목록·상세·글쓰기·수정·버전히스토리 5개 페이지 전체 예시가 있다 — 그대로 복사해서 시작해도 된다.
|
|
138
|
+
|
|
139
|
+
제공하는 컴포넌트: `MemiBoardList`, `MemiBoardDetail`, `MemiBoardEditor`, `MemiBoardCommentList`, `MemiBoardCommentForm`, `MemiBoardAttachments`, `MemiBoardSignIn`, `MemiBoardVersionHistory`.
|
|
140
|
+
제공하는 composable: `useMemiBoardAuth`, `useMemiBoardPosts`, `useMemiBoardComments`, `useMemiBoardStorage`, `useMemiBoardModeration`, `useMemiBoardSettings`.
|
|
141
|
+
|
|
142
|
+
### 카테고리
|
|
143
|
+
|
|
144
|
+
`useMemiBoardSettings()`가 `{prefix}Settings/config` 문서에서 카테고리 목록(`categories: { id, label }[]`)을 읽어온다. `MemiBoardEditor`는 이 목록으로 카테고리 선택 UI를 보여주고, `MemiBoardList`/`MemiBoardDetail`은 글의 `category` 필드를 뱃지로 표시한다.
|
|
145
|
+
|
|
146
|
+
설정 문서가 아직 없으면 기본 카테고리(자유/공지/질문)로 계속 동작한다 — 관리자(`{prefix}Users/{uid}.role == 'admin'`)가 게시판에 처음 들어오는 순간 그 기본값으로 문서가 자동 생성되고, 이후엔 Firebase 콘솔에서 `{prefix}Settings/config.categories` 배열을 직접 수정하면 된다(카테고리 관리 UI는 아직 없음).
|
|
147
|
+
|
|
148
|
+
### 로그인 공급자
|
|
149
|
+
|
|
150
|
+
`configureMemiBoard({ auth: { providers: [...] } })`의 기본값은 `['google', 'apple']`이고, `'emailPassword'`도 배열에 추가할 수 있다.
|
|
151
|
+
|
|
152
|
+
- **Google**: Firebase 콘솔 → Authentication → Sign-in method에서 Google 활성화만 하면 된다.
|
|
153
|
+
- **Apple**: Firebase 콘솔 활성화 외에 Apple Developer 쪽 Services ID·키 발급이 추가로 필요하다 — 순서대로: [docs/firebase-setup.md](docs/firebase-setup.md#apple). 이 설정 없이 버튼을 누르면 에러가 난다.
|
|
154
|
+
|
|
155
|
+
### 버전 히스토리 페이지
|
|
156
|
+
|
|
157
|
+
`<MemiBoardVersionHistory />` 컴포넌트를 원하는 페이지(예: `app/pages/board/version-history.vue`)에 배치하면, 게시판을 설치한 사이트의 방문자도 "이 게시판에 어떤 기능이 있는지/뭐가 달라졌는지" 바로 볼 수 있다.
|
|
158
|
+
|
|
159
|
+
## Security Model — 반드시 읽어야 하는 부분
|
|
160
|
+
|
|
161
|
+
이 패키지는 서버가 없다. **모든 권한 검증은 Firestore/Storage Security Rules가 전부다.** 아래 두 파일을 프로젝트에 병합하는 것만으로는 부족하다 — **실제로 Firebase에 배포해야** 글쓰기·댓글·첨부파일이 동작한다. 로컬 `.rules` 파일만 고쳐두고 배포를 잊으면 글쓰기 버튼을 눌렀을 때 `permission-denied`가 난다(가장 흔한 설치 실수).
|
|
162
|
+
|
|
163
|
+
1. [`docs/firestore.rules.example`](docs/firestore.rules.example) — 호스트 프로젝트의 `firestore.rules`에 병합
|
|
164
|
+
2. [`docs/storage.rules.example`](docs/storage.rules.example) — 호스트 프로젝트의 `storage.rules`에 병합
|
|
165
|
+
3. **배포까지 실행**:
|
|
166
|
+
```bash
|
|
167
|
+
firebase deploy --only firestore:rules,storage
|
|
168
|
+
```
|
|
169
|
+
(호스트 프로젝트에 이미 `firestore.rules`/`storage.rules`가 배포 파이프라인에 연결돼 있다면, 평소 쓰던 배포 명령에 이 파일들이 포함돼 있는지 확인한다. Firebase CLI를 처음 연결하는 프로젝트라면 [docs/firebase-setup.md](docs/firebase-setup.md#4-호스트-프로젝트를-firebase-cli에-연결)부터 본다.)
|
|
170
|
+
|
|
171
|
+
권한 모델:
|
|
172
|
+
- 글/댓글 작성 → 로그인 필요
|
|
173
|
+
- 수정/삭제 → 작성자 본인 또는 `role: 'admin'`
|
|
174
|
+
- 관리자 승격은 이 패키지가 절대 하지 않는다. Firebase 콘솔에서 `{collectionPrefix}Users/{uid}` 문서의 `role` 필드를 직접 `'admin'`으로 바꿔야 한다.
|
|
175
|
+
|
|
176
|
+
**알려진 한계**: AI 검열은 클라이언트에서만 실행된다. 악의적 사용자가 Firestore SDK로 직접 우회 쓰기하면 검열을 피할 수 있다 — 서버가 없는 아키텍처의 근본적인 한계이며, 이 패키지는 이를 해결하려 하지 않는다. 악용 위험이 큰 프로젝트라면 별도로 Cloud Functions 검증 트리거를 추가하는 것을 권장한다(이 패키지의 범위 밖).
|
|
177
|
+
|
|
178
|
+
## Firebase AI Logic (AI 검열)
|
|
179
|
+
|
|
180
|
+
`configureMemiBoard({ moderation: { enabled: true, ... } })`(기본값)면 글/댓글 제출 전에 로컬 비속어 필터 → Gemini 검열을 순서대로 실행하고, 걸리면 제출 자체를 막는다(`moderation.onError`로 검열 서비스 장애 시 허용/차단 정책을 정한다, 기본은 `'allow'`).
|
|
181
|
+
|
|
182
|
+
Firebase AI Logic을 쓰려면 Firebase 콘솔에서 AI Logic(Gemini Developer API)을 활성화해야 한다 — **무료(Spark) 요금제에서는 제한되거나 안 될 수 있어 Blaze(종량제)가 필요할 수 있다.** 남용 방지를 위해 App Check도 함께 설정하는 것을 권장한다 — App Check는 memi-board가 아니라 호스트의 `vuefire` 설정(`appCheck` 옵션)에서 켠다. **App Check는 옵션을 켠다고 자동으로 강제되지 않는다** — Firebase 콘솔 → App Check에서 Firestore/Storage 각각 "Enforce"를 따로 켜야 실제로 거부가 시작된다. 설정 단계는 [docs/firebase-setup.md](docs/firebase-setup.md#8-app-check-설정-선택-권장)에 자세히 있다.
|
|
183
|
+
|
|
184
|
+
## 문제 해결
|
|
185
|
+
|
|
186
|
+
### `[VueFire] useCurrentUser() called before the VueFireAuth module was added to the VueFire plugin`
|
|
187
|
+
|
|
188
|
+
`nuxt-vuefire`/`vuefire`/`firebase`를 프로젝트에 직접 설치하지 않았을 때, 또는 호스트의 `vuefire.auth.enabled`가 꺼져 있을 때 나는 에러다(위 [설치](#설치) 참고). pnpm 기준으로 `pnpm why vuefire`를 실행했을 때 `vuefire`가 두 개 이상의 서로 다른 버전/인스턴스로 나오면 그게 원인이다 — 하나로 합쳐질 때까지 lockfile을 정리하거나(`pnpm dedupe`), 세 패키지 버전을 프로젝트와 memi-board의 `peerDependencies` 범위에 맞게 맞춘다.
|
|
189
|
+
|
|
190
|
+
### 설치는 됐는데 카테고리·설정이 하나도 안 보인다
|
|
191
|
+
|
|
192
|
+
관리자(`role: 'admin'`)가 게시판에 한 번도 들어온 적이 없으면 `{prefix}Settings/config` 문서가 아직 자동 생성되지 않은 상태다 — 코드 내장 기본값(자유/공지/질문)으로 정상 동작 중인 것이니 에러가 아니다. 관리자 계정으로 게시판에 한 번 들어가면 생긴다.
|
|
193
|
+
|
|
194
|
+
## 로컬 개발 (이 리포 자체를 수정할 때)
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
pnpm install
|
|
198
|
+
pnpm build # src/ → dist/ (한 번 빌드해 둬야 playground가 'memi-board'를 resolve할 수 있다)
|
|
199
|
+
pnpm dev:prepare
|
|
200
|
+
pnpm dev # playground 실행, http://localhost:3000
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`src/`를 수정하면서 바로 반영해서 보고 싶으면 다른 터미널에서:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
pnpm build:watch # vite build --watch — src/ 변경 시마다 dist/를 다시 빌드
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Firebase 없이 빠르게 테스트하려면 [Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite)를 쓴다:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
firebase emulators:start --project demo-memi-board
|
|
213
|
+
NUXT_PUBLIC_USE_EMULATORS=1 pnpm dev
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## 버전 정보
|
|
217
|
+
|
|
218
|
+
- 사이트에 설치된 상태에서 보기: `<MemiBoardVersionHistory />` 컴포넌트를 원하는 페이지에 배치
|
|
219
|
+
- 코드 수준의 자세한 변경 내역: [CHANGELOG.md](CHANGELOG.md)
|
|
220
|
+
|
|
221
|
+
## 라이선스
|
|
222
|
+
|
|
223
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Attachment } from '../types';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
modelValue: Attachment[];
|
|
4
|
+
/** 새 글 작성 중이면 아직 slug가 없으므로 임시 네임스페이스를 넘긴다. */
|
|
5
|
+
postId: string;
|
|
6
|
+
editable?: boolean;
|
|
7
|
+
maxFiles?: number;
|
|
8
|
+
};
|
|
9
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
10
|
+
"update:modelValue": (attachments: Attachment[]) => any;
|
|
11
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
12
|
+
"onUpdate:modelValue"?: ((attachments: Attachment[]) => any) | undefined;
|
|
13
|
+
}>, {
|
|
14
|
+
editable: boolean;
|
|
15
|
+
maxFiles: number;
|
|
16
|
+
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
export default _default;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
postId: string;
|
|
3
|
+
};
|
|
4
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
5
|
+
export default _default;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
postId: string;
|
|
3
|
+
};
|
|
4
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
5
|
+
export default _default;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
postId: string;
|
|
3
|
+
};
|
|
4
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
5
|
+
deleted: () => any;
|
|
6
|
+
edit: (postId: string) => any;
|
|
7
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
8
|
+
onDeleted?: (() => any) | undefined;
|
|
9
|
+
onEdit?: ((postId: string) => any) | undefined;
|
|
10
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
11
|
+
export default _default;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
/** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */
|
|
3
|
+
postId?: string;
|
|
4
|
+
};
|
|
5
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
6
|
+
saved: (id: string) => any;
|
|
7
|
+
cancel: () => any;
|
|
8
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
9
|
+
onSaved?: ((id: string) => any) | undefined;
|
|
10
|
+
onCancel?: (() => any) | undefined;
|
|
11
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
12
|
+
export default _default;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PostModel } from '../types';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
pageSize?: number;
|
|
4
|
+
/** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */
|
|
5
|
+
linkBase?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
8
|
+
select: (post: PostModel) => any;
|
|
9
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
10
|
+
onSelect?: ((post: PostModel) => any) | undefined;
|
|
11
|
+
}>, {
|
|
12
|
+
pageSize: number;
|
|
13
|
+
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Ref, ComputedRef } from 'vue';
|
|
2
|
+
import { useCurrentUser } from 'vuefire';
|
|
3
|
+
import { signInWithPopup, signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'firebase/auth';
|
|
4
|
+
import { PostModel, CommentModel } from '../types';
|
|
5
|
+
export interface UseMemiBoardAuthReturn {
|
|
6
|
+
user: ReturnType<typeof useCurrentUser>;
|
|
7
|
+
isSignedIn: ComputedRef<boolean>;
|
|
8
|
+
isAdmin: ComputedRef<boolean>;
|
|
9
|
+
rolePending: Ref<boolean>;
|
|
10
|
+
signInWithGoogle: () => ReturnType<typeof signInWithPopup>;
|
|
11
|
+
signInWithApple: () => ReturnType<typeof signInWithPopup>;
|
|
12
|
+
signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>;
|
|
13
|
+
signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>;
|
|
14
|
+
signOut: () => Promise<void>;
|
|
15
|
+
canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean;
|
|
16
|
+
canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean;
|
|
17
|
+
canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 인증 상태 + 게시판 역할(role) 관리.
|
|
21
|
+
* role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다
|
|
22
|
+
* (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).
|
|
23
|
+
*/
|
|
24
|
+
export declare function useMemiBoardAuth(): UseMemiBoardAuthReturn;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
import { CommentModel } from '../types';
|
|
3
|
+
export interface AddCommentInput {
|
|
4
|
+
body: string;
|
|
5
|
+
authorUid: string;
|
|
6
|
+
authorName: string | null;
|
|
7
|
+
authorPhoto: string | null;
|
|
8
|
+
}
|
|
9
|
+
/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */
|
|
10
|
+
export declare function useMemiBoardComments(postId: string | Ref<string>): {
|
|
11
|
+
comments: import('vuefire')._RefFirestore<import('vuefire').VueFirestoreQueryData<CommentModel>>;
|
|
12
|
+
addComment: (input: AddCommentInput) => Promise<void>;
|
|
13
|
+
deleteComment: (commentId: string) => Promise<void>;
|
|
14
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ModerationResult } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* 글쓰기 전 블로킹 검열. 로컬 비속어 필터를 먼저 돌리고, 통과하면 Firebase AI Logic(Gemini)으로 재검사한다.
|
|
4
|
+
* 검열 API 장애 시 동작은 moderation.onError 옵션을 따른다 (기본 'allow' — 검열 장애로 정상 글쓰기가 막히지 않도록).
|
|
5
|
+
*
|
|
6
|
+
* 알려진 한계: 이 검열은 클라이언트에서만 실행되므로, Firestore에 직접 쓰기하면 우회할 수 있다.
|
|
7
|
+
* (서버가 없는 아키텍처의 근본적 한계 — README의 Security Model 참고)
|
|
8
|
+
*/
|
|
9
|
+
export declare function useMemiBoardModeration(): {
|
|
10
|
+
checkText: (text: string) => Promise<ModerationResult>;
|
|
11
|
+
checkImage: (file: File) => Promise<ModerationResult>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore';
|
|
2
|
+
import { Attachment, PostDetail, PostModel } from '../types';
|
|
3
|
+
export interface CreatePostInput {
|
|
4
|
+
title: string;
|
|
5
|
+
content: string;
|
|
6
|
+
tags?: string[];
|
|
7
|
+
category?: string;
|
|
8
|
+
attachments?: Attachment[];
|
|
9
|
+
authorUid: string;
|
|
10
|
+
authorName: string | null;
|
|
11
|
+
authorPhoto: string | null;
|
|
12
|
+
moderationModel?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface UpdatePostInput {
|
|
15
|
+
title: string;
|
|
16
|
+
content: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
category?: string;
|
|
19
|
+
attachments?: Attachment[];
|
|
20
|
+
}
|
|
21
|
+
/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */
|
|
22
|
+
export declare function useMemiBoardPosts(): {
|
|
23
|
+
getPosts: (opts?: {
|
|
24
|
+
pageSize?: number;
|
|
25
|
+
cursor?: QueryDocumentSnapshot<DocumentData>;
|
|
26
|
+
}) => Promise<{
|
|
27
|
+
posts: PostModel[];
|
|
28
|
+
cursor: QueryDocumentSnapshot<DocumentData> | undefined;
|
|
29
|
+
hasMore: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
getPost: (id: string) => Promise<PostDetail | null>;
|
|
32
|
+
createPost: (input: CreatePostInput) => Promise<string>;
|
|
33
|
+
updatePost: (id: string, input: UpdatePostInput) => Promise<void>;
|
|
34
|
+
deletePost: (id: string) => Promise<void>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BoardCategory } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* 게시판 설정({prefix}Settings/config) — 지금은 카테고리 옵션만 다룬다.
|
|
4
|
+
* 문서가 없으면 기본 카테고리를 그대로 보여주고, 관리자가 게시판을 방문하는 순간
|
|
5
|
+
* 그 기본값으로 문서를 한 번 만들어 둔다(이후엔 Firebase 콘솔/향후 관리 UI에서 직접 수정).
|
|
6
|
+
* 일반 사용자는 절대 이 문서를 쓰지 않는다(생성 실패해도 조용히 무시 — 기본값으로 계속 동작).
|
|
7
|
+
*/
|
|
8
|
+
export declare function useMemiBoardSettings(): {
|
|
9
|
+
categories: import('vue').ComputedRef<BoardCategory[]>;
|
|
10
|
+
categoryLabel: (id: string | undefined) => string | undefined;
|
|
11
|
+
settingsPending: import('vue').Ref<boolean, boolean>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Attachment } from '../types';
|
|
2
|
+
export declare function useMemiBoardStorage(): {
|
|
3
|
+
uploadAttachment: (file: File, postId: string, onProgress?: (ratio: number) => void) => {
|
|
4
|
+
promise: Promise<Attachment>;
|
|
5
|
+
cancel: () => void;
|
|
6
|
+
};
|
|
7
|
+
deleteAttachment: (attachment: Pick<Attachment, "path">) => Promise<void>;
|
|
8
|
+
};
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface MemiBoardAuthOptions {
|
|
2
|
+
providers?: Array<'google' | 'apple' | 'emailPassword'>;
|
|
3
|
+
}
|
|
4
|
+
export interface MemiBoardModerationOptions {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
model?: string;
|
|
7
|
+
localBlocklist?: string[];
|
|
8
|
+
onError?: 'allow' | 'block';
|
|
9
|
+
moderateImages?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface MemiBoardConfig {
|
|
12
|
+
collectionPrefix: string;
|
|
13
|
+
auth: MemiBoardAuthOptions;
|
|
14
|
+
moderation: MemiBoardModerationOptions;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로
|
|
18
|
+
* 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function configureMemiBoard(options: Partial<MemiBoardConfig>): void;
|
|
21
|
+
export declare function useMemiBoardConfig(): MemiBoardConfig;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface VersionHistoryEntry {
|
|
2
|
+
version: string;
|
|
3
|
+
date: string;
|
|
4
|
+
highlights: string[];
|
|
5
|
+
note?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.
|
|
9
|
+
* 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.
|
|
10
|
+
*/
|
|
11
|
+
export declare const versionHistory: VersionHistoryEntry[];
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { configureMemiBoard, useMemiBoardConfig } from './config';
|
|
2
|
+
export type { MemiBoardConfig, MemiBoardAuthOptions, MemiBoardModerationOptions } from './config';
|
|
3
|
+
export { useMemiBoardAuth } from './composables/useMemiBoardAuth';
|
|
4
|
+
export { useMemiBoardPosts } from './composables/useMemiBoardPosts';
|
|
5
|
+
export type { CreatePostInput, UpdatePostInput } from './composables/useMemiBoardPosts';
|
|
6
|
+
export { useMemiBoardComments } from './composables/useMemiBoardComments';
|
|
7
|
+
export type { AddCommentInput } from './composables/useMemiBoardComments';
|
|
8
|
+
export { useMemiBoardModeration } from './composables/useMemiBoardModeration';
|
|
9
|
+
export { useMemiBoardStorage } from './composables/useMemiBoardStorage';
|
|
10
|
+
export { useMemiBoardSettings } from './composables/useMemiBoardSettings';
|
|
11
|
+
export { default as MemiBoardList } from './components/List.vue';
|
|
12
|
+
export { default as MemiBoardDetail } from './components/Detail.vue';
|
|
13
|
+
export { default as MemiBoardEditor } from './components/Editor.vue';
|
|
14
|
+
export { default as MemiBoardCommentList } from './components/CommentList.vue';
|
|
15
|
+
export { default as MemiBoardCommentForm } from './components/CommentForm.vue';
|
|
16
|
+
export { default as MemiBoardAttachments } from './components/Attachments.vue';
|
|
17
|
+
export { default as MemiBoardSignIn } from './components/SignIn.vue';
|
|
18
|
+
export { default as MemiBoardVersionHistory } from './components/VersionHistory.vue';
|
|
19
|
+
export { versionHistory } from './data/versionHistory';
|
|
20
|
+
export type { VersionHistoryEntry } from './data/versionHistory';
|
|
21
|
+
export type { Attachment, ModerationStatus, PostModel, PostDetail, CommentModel, BoardUserRole, BoardCategory, BoardSettingsModel, ModerationResult, } from './types';
|