memi-board 0.62.0 → 0.63.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/dist/{commentImages-DVrG0nFm.js → commentImages-xb5Y4ySe.js} +12 -5
- package/dist/{commentImages-DVrG0nFm.js.map → commentImages-xb5Y4ySe.js.map} +1 -1
- package/dist/composables/useMemiBoardBannerGeneration.d.ts +1 -0
- package/dist/composables/useMemiBoardStorage.d.ts +2 -1
- package/dist/{en-PEe41L86.js → en-I7auZHra.js} +10 -3
- package/dist/{en-PEe41L86.js.map → en-I7auZHra.js.map} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +145 -124
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/BannerEditor.vue +105 -52
- package/dist/runtime/components/CategorySettings.vue +10 -12
- package/dist/runtime/components/CustomFieldEditor.vue +85 -1
- package/dist/runtime/components/List.vue +19 -8
- package/dist/runtime/components/Settings.vue +10 -11
- package/dist/storage.js +10 -1
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/bannerGeneration.d.ts +1 -1
- package/dist/utils/customFields.d.ts +1 -1
- package/docs/firebase-setup.md +1 -1
- package/package.json +1 -1
package/dist/storage.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["import type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\n\n/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n locale?: MemiBoardLocale,\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error(translate(locale, 'storage.canvasUnavailable')))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error(translate(locale, 'storage.compressFailed')))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error(translate(locale, 'storage.loadFailed')))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { getAuth } from 'firebase/auth'\nimport { isCommentImageFile } from '../utils/commentImages'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, BoardBanner, EditorImageEntry } from '../types'\nimport type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\nimport { useMemiBoardI18n } from '../i18n/useMemiBoardI18n'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** 압축해도 5MB 를 넘는 경우 — 다른 최적화 실패와 구분해 그대로 보여 준다. */\nclass ImageTooLargeError extends Error {}\n\nasync function resizeJpegIfNeeded(blob: Blob, locale?: MemiBoardLocale): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 }, locale)\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale)\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new ImageTooLargeError(translate(locale, 'storage.tooLargeAfterCompress'))\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF 원본 파일 → 호환 형식 안내\n */\nasync function optimizeEditorImage(file: File, locale?: MemiBoardLocale): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(translate(locale, 'storage.sourceTooLarge', { max: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))\n }\n\n if (isHeicLike(file)) {\n throw new Error(translate(locale, 'storage.heicUnsupported'))\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file, locale)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof ImageTooLargeError) throw cause\n throw new Error(translate(locale, 'storage.cannotOptimize'))\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n const { t, locale } = useMemiBoardI18n()\n\n function uploadMetadata() {\n const uid = getAuth(app).currentUser?.uid\n if (!uid) throw new Error('Sign in before uploading a file.')\n return { ownerUid: uid }\n }\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n customMetadata: uploadMetadata(),\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 댓글 이미지 1장.\n * `memiBoardPosts/{postId}/comment-images/{commentId}/{ts}-{name}.ext`\n * 댓글 문서보다 먼저 올릴 수 있다. 저장 상한은 본문 이미지와 같은 5MB.\n */\n function uploadCommentImage(\n file: File,\n postId: string,\n commentId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n if (!isCommentImageFile(file)) throw new Error(t('commentForm.imageOnly'))\n if (!/^[A-Za-z0-9]{10,128}$/.test(postId) || !/^[A-Za-z0-9]{10,128}$/.test(commentId)) {\n throw new Error(t('commentForm.uploadFailed'))\n }\n\n const metadata = uploadMetadata()\n const storage = getStorage(app)\n let task: UploadTask | undefined\n let cancelled = false\n const promise = (async () => {\n const optimized = await optimizeEditorImage(file, locale.value)\n if (cancelled) throw new Error(t('commentForm.uploadFailed'))\n let blob = optimized.blob\n if (blob.size >= EDITOR_IMAGE_MAX_BYTES) {\n blob = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale.value)\n }\n if (cancelled) throw new Error(t('commentForm.uploadFailed'))\n if (blob.size >= EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(t('storage.tooLargeAfterCompress'))\n }\n const type = (blob.type || file.type || 'image/jpeg').toLowerCase()\n const ext = type === 'image/png'\n ? 'png'\n : type === 'image/gif'\n ? 'gif'\n : type === 'image/webp'\n ? 'webp'\n : 'jpg'\n const stem = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40) || 'image'\n const path = `${postStorageFolder(cfg(), postId)}/comment-images/${commentId}/${Date.now()}-${stem}.${ext}`\n const fileRef = storageRef(storage, path)\n task = uploadBytesResumable(fileRef, blob, {\n customMetadata: metadata,\n contentType: type,\n })\n if (cancelled) {\n task.cancel()\n throw new Error(t('commentForm.uploadFailed'))\n }\n await new Promise<void>((resolve, reject) => {\n task!.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n () => resolve(),\n )\n })\n const url = await getDownloadURL(fileRef)\n return {\n name: file.name || `image.${ext}`,\n url,\n path,\n size: blob.size,\n type,\n }\n })()\n\n return {\n promise,\n cancel: () => {\n cancelled = true\n task?.cancel()\n },\n }\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error(t('storage.imageOnly'))\n }\n const optimized = await optimizeEditorImage(file, locale.value)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n customMetadata: uploadMetadata(),\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 }, locale.value)\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n async function uploadBoardBanner(boardId: string, file: File): Promise<BoardBanner> {\n if (!boardId || boardId.includes('/') || file.type !== 'image/jpeg' || file.size >= 3 * 1024 * 1024) {\n throw new Error(t('settings.banner.invalid'))\n }\n const fileRef = storageRef(getStorage(app), `${cfg().settingsCollection}/${boardId}/banners/${crypto.randomUUID()}.jpg`)\n await uploadBytes(fileRef, file, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n try {\n return { url: await getDownloadURL(fileRef), path: fileRef.fullPath }\n }\n catch (cause) {\n await deleteObject(fileRef).catch(() => {})\n throw cause\n }\n }\n\n return {\n uploadBoardBanner,\n uploadAttachment,\n deleteAttachment,\n uploadCommentImage,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":";;;;;AAMA,eAAsB,EACpB,GACA,EAAE,cAAW,KAAK,aAAU,OAAiD,CAAC,GAC9E,GACe;CACf,OAAO,IAAI,SAAS,GAAS,MAAW;EACtC,IAAM,IAAM,IAAI,MAAM,GAChB,IAAY,IAAI,gBAAgB,CAAI;EA6B1C,AA3BA,EAAI,eAAe;GACjB,IAAI,gBAAgB,CAAS;GAE7B,IAAM,IAAQ,KAAK,IAAI,GAAG,IAAW,EAAI,YAAY,GAC/C,IAAS,SAAS,cAAc,QAAQ;GAE9C,AADA,EAAO,QAAQ,KAAK,MAAM,EAAI,eAAe,CAAK,GAClD,EAAO,SAAS,KAAK,MAAM,EAAI,gBAAgB,CAAK;GAEpD,IAAM,IAAM,EAAO,WAAW,IAAI;GAClC,IAAI,CAAC,GAAK;IACR,EAAW,MAAM,EAAU,GAAQ,2BAA2B,CAAC,CAAC;IAChE;GACF;GAGA,AAFA,EAAI,UAAU,GAAK,GAAG,GAAG,EAAO,OAAO,EAAO,MAAM,GAEpD,EAAO,QACL,MAAS,IAAO,EAAQ,CAAI,IAAI,EAAW,MAAM,EAAU,GAAQ,wBAAwB,CAAC,CAAC,GAC7F,cACA,CACF;EACF,GAEA,EAAI,gBAAgB;GAElB,AADA,IAAI,gBAAgB,CAAS,GAC7B,EAAW,MAAM,EAAU,GAAQ,oBAAoB,CAAC,CAAC;EAC3D,GAEA,EAAI,MAAM;CACZ,CAAC;AACH;;;ACtBA,IAAa,IAAyB,SAEzB,IAAgC;AAE7C,SAAS,EAAW,GAAqB;CACvC,IAAM,KAAQ,EAAK,QAAQ,GAAA,CAAI,YAAY,GACrC,IAAO,EAAK,KAAK,YAAY;CACnC,OAAO,MAAS,gBACX,MAAS,gBACT,EAAK,SAAS,OAAO,KACrB,EAAK,SAAS,OAAO;AAC5B;AAGA,IAAM,IAAN,cAAiC,MAAM,CAAC;AAExC,eAAe,EAAmB,GAAY,GAAyC;CACrF,IAAI,EAAK,QAAA,SAAgC,OAAO;CAChD,IAAI,IAAO,MAAM,EAAc,GAAM;EAAE,UAAU;EAAM,SAAS;CAAK,GAAG,CAAM;CAI9E,IAHI,EAAK,OAAA,YACP,IAAO,MAAM,EAAc,GAAM;EAAE,UAAU;EAAM,SAAS;CAAK,GAAG,CAAM,IAExE,EAAK,OAAA,SACP,MAAM,IAAI,EAAmB,EAAU,GAAQ,+BAA+B,CAAC;CAEjF,OAAO;AACT;AAOA,eAAe,EAAoB,GAAY,GAA+E;CAC5H,IAAI,EAAK,OAAA,UACP,MAAU,MAAM,EAAU,GAAQ,0BAA0B,EAAE,KAAK,IAAgC,OAAO,KAAK,CAAC,CAAC;CAGnH,IAAI,EAAW,CAAI,GACjB,MAAU,MAAM,EAAU,GAAQ,yBAAyB,CAAC;CAG9D,IAAI,EAAK,QAAA,SACP,OAAO;EAAE,MAAM;EAAM,YAAY;CAAM;CAGzC,IAAI;EAEF,OAAO;GAAE,MAAA,MADU,EAAmB,GAAM,CAAM;GACnC,YAAY;EAAK;CAClC,SACO,GAAO;EAEZ,MADI,aAAiB,IAA0B,IACrC,MAAM,EAAU,GAAQ,wBAAwB,CAAC;CAC7D;AACF;AAEA,SAAgB,IAAsB;CACpC,IAAM,UAAY,EAAmB,GAC/B,IAAM,EAAe,GACrB,EAAE,MAAG,cAAW,EAAiB;CAEvC,SAAS,IAAiB;EACxB,IAAM,IAAM,EAAQ,CAAG,CAAC,CAAC,aAAa;EACtC,IAAI,CAAC,GAAK,MAAU,MAAM,kCAAkC;EAC5D,OAAO,EAAE,UAAU,EAAI;CACzB;CAGA,SAAS,EACP,GACA,GACA,GACsD;EACtD,IAAM,IAAU,EAAW,CAAG,GACxB,IAAO,GAAG,EAAkB,EAAI,GAAG,CAAM,EAAE,eAAe,KAAK,IAAI,EAAE,GAAG,EAAa,EAAK,IAAI,KAC9F,IAAU,EAAW,GAAS,CAAI,GAClC,IAAmB,EAAqB,GAAS,GAAM;GAC3D,gBAAgB,EAAe;GAC/B,aAAa,EAAK,QAAQ;GAC1B,oBAAoB,gCAAgC,mBAAmB,EAAK,IAAI;EAClF,CAAC;EAkBD,OAAO;GAAE,SAAA,IAhBW,SAAqB,GAAS,MAAW;IAC3D,EAAK,GACH,kBACC,MAAa;KACZ,AAAI,KAAc,EAAS,aAAa,KACtC,EAAW,EAAS,mBAAmB,EAAS,UAAU;IAE9D,GACA,GACA,YAAY;KACV,IAAM,IAAM,MAAM,EAAe,CAAO;KACxC,EAAQ;MAAE,MAAM,EAAK;MAAM;MAAK;MAAM,MAAM,EAAK;MAAM,MAAM,EAAK;KAAK,CAAC;IAC1E,CACF;GACF,CAES;GAAS,cAAc,EAAK,OAAO;EAAE;CAChD;CAEA,eAAe,EAAiB,GAAqD;EACnF,IAAM,IAAU,EAAW,CAAG;EAC9B,MAAM,EAAa,EAAW,GAAS,EAAW,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;CACzE;CAOA,SAAS,EACP,GACA,GACA,GACA,GACsD;EACtD,IAAI,CAAC,EAAmB,CAAI,GAAG,MAAU,MAAM,EAAE,uBAAuB,CAAC;EACzE,IAAI,CAAC,wBAAwB,KAAK,CAAM,KAAK,CAAC,wBAAwB,KAAK,CAAS,GAClF,MAAU,MAAM,EAAE,0BAA0B,CAAC;EAG/C,IAAM,IAAW,EAAe,GAC1B,IAAU,EAAW,CAAG,GAC1B,GACA,IAAY;EAqDhB,OAAO;GACL,UArDe,YAAY;IAC3B,IAAM,IAAY,MAAM,EAAoB,GAAM,EAAO,KAAK;IAC9D,IAAI,GAAW,MAAU,MAAM,EAAE,0BAA0B,CAAC;IAC5D,IAAI,IAAO,EAAU;IAIrB,IAHI,EAAK,QAAA,YACP,IAAO,MAAM,EAAc,GAAM;KAAE,UAAU;KAAM,SAAS;IAAK,GAAG,EAAO,KAAK,IAE9E,GAAW,MAAU,MAAM,EAAE,0BAA0B,CAAC;IAC5D,IAAI,EAAK,QAAA,SACP,MAAU,MAAM,EAAE,+BAA+B,CAAC;IAEpD,IAAM,KAAQ,EAAK,QAAQ,EAAK,QAAQ,aAAA,CAAc,YAAY,GAC5D,IAAM,MAAS,cACjB,QACA,MAAS,cACP,QACA,MAAS,eACP,SACA,OACF,IAAO,EAAa,EAAK,KAAK,QAAQ,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,SACzE,IAAO,GAAG,EAAkB,EAAI,GAAG,CAAM,EAAE,kBAAkB,EAAU,GAAG,KAAK,IAAI,EAAE,GAAG,EAAK,GAAG,KAChG,IAAU,EAAW,GAAS,CAAI;IAKxC,IAJA,IAAO,EAAqB,GAAS,GAAM;KACzC,gBAAgB;KAChB,aAAa;IACf,CAAC,GACG,GAEF,MADA,EAAK,OAAO,GACF,MAAM,EAAE,0BAA0B,CAAC;IAE/C,MAAM,IAAI,SAAe,GAAS,MAAW;KAC3C,EAAM,GACJ,kBACC,MAAa;MACZ,AAAI,KAAc,EAAS,aAAa,KACtC,EAAW,EAAS,mBAAmB,EAAS,UAAU;KAE9D,GACA,SACM,EAAQ,CAChB;IACF,CAAC;IACD,IAAM,IAAM,MAAM,EAAe,CAAO;IACxC,OAAO;KACL,MAAM,EAAK,QAAQ,SAAS;KAC5B;KACA;KACA,MAAM,EAAK;KACX;IACF;GACF,EAAA,CAGE;GACA,cAAc;IAEZ,AADA,IAAY,IACZ,GAAM,OAAO;GACf;EACF;CACF;CAQA,eAAe,EAAkB,GAAY,GAA2C;EACtF,IAAI,CAAC,EAAK,KAAK,WAAW,QAAQ,GAChC,MAAU,MAAM,EAAE,mBAAmB,CAAC;EAExC,IAAM,IAAY,MAAM,EAAoB,GAAM,EAAO,KAAK,GAExD,IAAU,EAAW,CAAG,GACxB,KAAO,EAAU,aAAa,QAAS,EAAK,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,EAAK,KAAK,MAAM,GAAG,CAAC,CAAC,MAAM,MAAA,CACnG,QAAQ,iBAAiB,EAAE,CAAC,CAC5B,MAAM,GAAG,CAAC,KAAK,OACZ,IAAW,EAAa,EAAK,KAAK,QAAQ,YAAY,EAAE,KAAK,SAAS,EAAE,GACxE,IAAW,GAAG,KAAK,IAAI,EAAE,GAAG,KAAY,WACxC,IAAK,KAAU,OAAO,KAAK,IAAI,KAC/B,IAAS,EAAkB,EAAI,GAAG,CAAE,GAEpC,IAAe,GAAG,EAAO,UAAU,EAAS,GAAG,KAC/C,IAAc,EAAW,GAAS,CAAY;EACpD,MAAM,EAAY,GAAa,EAAU,MAAM;GAC7C,gBAAgB,EAAe;GAC/B,aAAa,EAAU,KAAK,QAAQ;EACtC,CAAC;EACD,IAAM,IAAc,MAAM,EAAe,CAAW,GAE9C,IAAY,MAAM,EAAc,EAAU,MAAM;GAAE,UAAU;GAAK,SAAS;EAAI,GAAG,EAAO,KAAK,GAC7F,IAAgB,GAAG,EAAO,qBAAqB,EAAS,OACxD,IAAW,EAAW,GAAS,CAAa;EAIlD,OAHA,MAAM,EAAY,GAAU,GAAW;GAAE,aAAa;GAAc,gBAAgB,EAAe;EAAE,CAAC,GAG/F;GAAE;GAAa;GAAc,cAAA,MAFT,EAAe,CAAQ;GAEA;EAAc;CAClE;CAGA,eAAe,EAAkB,GAAgF;EAC/G,IAAM,IAAU,EAAW,CAAG;EAC9B,MAAM,QAAQ,IAAI,CAChB,EAAa,EAAW,GAAS,EAAM,YAAY,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,GACpE,EAAa,EAAW,GAAS,EAAM,aAAa,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CACvE,CAAC;CACH;CAMA,eAAe,EAAa,GAAa,GAA6B;EACpE,IAAM,IAAU,EAAW,CAAG,GACxB,IAAO,GAAG,EAAI,CAAC,CAAC,gBAAgB,GAAG,EAAI,cACvC,IAAU,EAAW,GAAS,CAAI;EAExC,OADA,MAAM,EAAY,GAAS,GAAM,EAAE,aAAa,EAAK,QAAQ,aAAa,CAAC,GACpE,EAAe,CAAO;CAC/B;CAEA,eAAe,EAAkB,GAAiB,GAAkC;EAClF,IAAI,CAAC,KAAW,EAAQ,SAAS,GAAG,KAAK,EAAK,SAAS,gBAAgB,EAAK,QAAQ,SAClF,MAAU,MAAM,EAAE,yBAAyB,CAAC;EAE9C,IAAM,IAAU,EAAW,EAAW,CAAG,GAAG,GAAG,EAAI,CAAC,CAAC,mBAAmB,GAAG,EAAQ,WAAW,OAAO,WAAW,EAAE,KAAK;EACvH,MAAM,EAAY,GAAS,GAAM;GAAE,aAAa;GAAc,gBAAgB,EAAe;EAAE,CAAC;EAChG,IAAI;GACF,OAAO;IAAE,KAAK,MAAM,EAAe,CAAO;IAAG,MAAM,EAAQ;GAAS;EACtE,SACO,GAAO;GAEZ,MADA,MAAM,EAAa,CAAO,CAAC,CAAC,YAAY,CAAC,CAAC,GACpC;EACR;CACF;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["import type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\n\n/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n locale?: MemiBoardLocale,\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error(translate(locale, 'storage.canvasUnavailable')))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error(translate(locale, 'storage.compressFailed')))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error(translate(locale, 'storage.loadFailed')))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { getAuth } from 'firebase/auth'\nimport { isCommentImageFile } from '../utils/commentImages'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, BoardBanner, BoardBannerFiles, BoardModel, EditorImageEntry } from '../types'\nimport type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\nimport { useMemiBoardI18n } from '../i18n/useMemiBoardI18n'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** 압축해도 5MB 를 넘는 경우 — 다른 최적화 실패와 구분해 그대로 보여 준다. */\nclass ImageTooLargeError extends Error {}\n\nasync function resizeJpegIfNeeded(blob: Blob, locale?: MemiBoardLocale): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 }, locale)\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale)\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new ImageTooLargeError(translate(locale, 'storage.tooLargeAfterCompress'))\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF 원본 파일 → 호환 형식 안내\n */\nasync function optimizeEditorImage(file: File, locale?: MemiBoardLocale): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(translate(locale, 'storage.sourceTooLarge', { max: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))\n }\n\n if (isHeicLike(file)) {\n throw new Error(translate(locale, 'storage.heicUnsupported'))\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file, locale)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof ImageTooLargeError) throw cause\n throw new Error(translate(locale, 'storage.cannotOptimize'))\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n const { t, locale } = useMemiBoardI18n()\n\n function uploadMetadata() {\n const uid = getAuth(app).currentUser?.uid\n if (!uid) throw new Error('Sign in before uploading a file.')\n return { ownerUid: uid }\n }\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n customMetadata: uploadMetadata(),\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 댓글 이미지 1장.\n * `memiBoardPosts/{postId}/comment-images/{commentId}/{ts}-{name}.ext`\n * 댓글 문서보다 먼저 올릴 수 있다. 저장 상한은 본문 이미지와 같은 5MB.\n */\n function uploadCommentImage(\n file: File,\n postId: string,\n commentId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n if (!isCommentImageFile(file)) throw new Error(t('commentForm.imageOnly'))\n if (!/^[A-Za-z0-9]{10,128}$/.test(postId) || !/^[A-Za-z0-9]{10,128}$/.test(commentId)) {\n throw new Error(t('commentForm.uploadFailed'))\n }\n\n const metadata = uploadMetadata()\n const storage = getStorage(app)\n let task: UploadTask | undefined\n let cancelled = false\n const promise = (async () => {\n const optimized = await optimizeEditorImage(file, locale.value)\n if (cancelled) throw new Error(t('commentForm.uploadFailed'))\n let blob = optimized.blob\n if (blob.size >= EDITOR_IMAGE_MAX_BYTES) {\n blob = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale.value)\n }\n if (cancelled) throw new Error(t('commentForm.uploadFailed'))\n if (blob.size >= EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(t('storage.tooLargeAfterCompress'))\n }\n const type = (blob.type || file.type || 'image/jpeg').toLowerCase()\n const ext = type === 'image/png'\n ? 'png'\n : type === 'image/gif'\n ? 'gif'\n : type === 'image/webp'\n ? 'webp'\n : 'jpg'\n const stem = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40) || 'image'\n const path = `${postStorageFolder(cfg(), postId)}/comment-images/${commentId}/${Date.now()}-${stem}.${ext}`\n const fileRef = storageRef(storage, path)\n task = uploadBytesResumable(fileRef, blob, {\n customMetadata: metadata,\n contentType: type,\n })\n if (cancelled) {\n task.cancel()\n throw new Error(t('commentForm.uploadFailed'))\n }\n await new Promise<void>((resolve, reject) => {\n task!.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n () => resolve(),\n )\n })\n const url = await getDownloadURL(fileRef)\n return {\n name: file.name || `image.${ext}`,\n url,\n path,\n size: blob.size,\n type,\n }\n })()\n\n return {\n promise,\n cancel: () => {\n cancelled = true\n task?.cancel()\n },\n }\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error(t('storage.imageOnly'))\n }\n const optimized = await optimizeEditorImage(file, locale.value)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n customMetadata: uploadMetadata(),\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 }, locale.value)\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n async function uploadBoardBanner(boardId: string, file: File): Promise<BoardBanner> {\n if (!boardId || boardId.includes('/') || file.type !== 'image/jpeg' || file.size >= 3 * 1024 * 1024) {\n throw new Error(t('settings.banner.invalid'))\n }\n const fileRef = storageRef(getStorage(app), `${cfg().settingsCollection}/${boardId}/banners/${crypto.randomUUID()}.jpg`)\n await uploadBytes(fileRef, file, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n try {\n return { url: await getDownloadURL(fileRef), path: fileRef.fullPath }\n }\n catch (cause) {\n await deleteObject(fileRef).catch(() => {})\n throw cause\n }\n }\n\n /**\n * Uploads staged desktop/mobile banners onto the board and returns the new paths.\n * If any upload fails, the ones already uploaded are deleted before rethrowing.\n */\n async function applyBoardBannerFiles(board: Pick<BoardModel, 'id' | 'banner' | 'mobileBanner'>, files: BoardBannerFiles | undefined): Promise<string[]> {\n const uploaded: string[] = []\n try {\n if (files?.desktop !== undefined) {\n board.banner = files.desktop ? await uploadBoardBanner(board.id, files.desktop) : null\n if (board.banner) uploaded.push(board.banner.path)\n }\n if (files?.mobile !== undefined) {\n board.mobileBanner = files.mobile ? await uploadBoardBanner(board.id, files.mobile) : null\n if (board.mobileBanner) uploaded.push(board.mobileBanner.path)\n }\n return uploaded\n }\n catch (cause) {\n await Promise.all(uploaded.map(path => deleteObject(storageRef(getStorage(app), path)).catch(() => {})))\n throw cause\n }\n }\n\n return {\n uploadBoardBanner,\n applyBoardBannerFiles,\n uploadAttachment,\n deleteAttachment,\n uploadCommentImage,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -283,6 +283,10 @@ export interface BoardBanner {
|
|
|
283
283
|
url: string;
|
|
284
284
|
path: string;
|
|
285
285
|
}
|
|
286
|
+
/** desktop = 8:1 list banner, mobile = optional 3:1 banner for phones. */
|
|
287
|
+
export type BoardBannerKind = 'desktop' | 'mobile';
|
|
288
|
+
/** Staged banner changes: File uploads on save, null removes, undefined keeps. */
|
|
289
|
+
export type BoardBannerFiles = Partial<Record<BoardBannerKind, File | null>>;
|
|
286
290
|
export interface BoardModel {
|
|
287
291
|
/** boardId (예: 'notice', 'free') */
|
|
288
292
|
id: string;
|
|
@@ -340,6 +344,8 @@ export interface BoardModel {
|
|
|
340
344
|
customFields?: BoardCustomField[];
|
|
341
345
|
/** 목록 상단의 8:1 배너 이미지. */
|
|
342
346
|
banner?: BoardBanner | null;
|
|
347
|
+
/** 휴대폰용 3:1 배너. 없으면 휴대폰에서도 banner 를 쓴다. */
|
|
348
|
+
mobileBanner?: BoardBanner | null;
|
|
343
349
|
/** 보드 목록 정렬 */
|
|
344
350
|
order?: number;
|
|
345
351
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Board copy describes the theme; it is not rendered as text in the image. */
|
|
2
|
-
export declare function bannerGenerationPrompt(title: string, description: string, additionalRequest?: string): string;
|
|
2
|
+
export declare function bannerGenerationPrompt(title: string, description: string, additionalRequest?: string, kind?: 'desktop' | 'mobile'): string;
|
|
3
3
|
/** Validate the response before creating a local preview; never treat returned text as a URL. */
|
|
4
4
|
export declare function generatedBannerBlob(parts: Array<{
|
|
5
5
|
inlineData: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BoardCustomField } from '../types';
|
|
2
2
|
/** 보드당 커스텀 필드 개수. 글의 extend 키와 1:1 이다. */
|
|
3
|
-
export declare const CUSTOM_FIELD_MAX =
|
|
3
|
+
export declare const CUSTOM_FIELD_MAX = 20;
|
|
4
4
|
export declare const CUSTOM_FIELD_LABEL_MAX = 40;
|
|
5
5
|
/** 글쓰기 입력칸에 희미하게 보여 주는 예시. */
|
|
6
6
|
export declare const CUSTOM_FIELD_PLACEHOLDER_MAX = 80;
|
package/docs/firebase-setup.md
CHANGED
|
@@ -189,7 +189,7 @@ File reads and listing in the example remain public. Services that need files of
|
|
|
189
189
|
|
|
190
190
|
### Board banner Storage rules
|
|
191
191
|
|
|
192
|
-
Board settings support an optional 8:1 banner. The crop editor exports a 2400 × 300 JPEG, previews it locally, and uploads only when settings are saved. The saved board document contains `banner: { url, path }`; removing
|
|
192
|
+
Board settings support an optional 8:1 desktop banner and an optional 3:1 phone banner. The crop editor exports a 2400 × 300 (desktop) or 1200 × 400 (phone) JPEG, previews it locally, and uploads only when settings are saved. The saved board document contains `banner: { url, path }` and `mobileBanner: { url, path }`; removing one stores `null`. The list shows the phone banner below 640px and the desktop banner above it; when only one is set, it is used on both.
|
|
193
193
|
|
|
194
194
|
Banner files use `memiBoardSettings/{boardId}/banners/{uuid}.jpg`. Merge **both** `canConfigureBanner(boardId)` and its `match /memiBoardSettings/{boardId}/banners/{fileName}` block from [storage.rules.example](storage.rules.example) into the host's existing `match /b/{bucket}/o` block. Keep the existing `isSignedIn()` helper, including its account-deletion check, and retain other application rules. The Storage path uses the configured settings collection; if `memiBoard.settingsCollection` or `usersCollection` is customized, update the corresponding paths in the rules too.
|
|
195
195
|
|