diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f5542ce..b4d924a 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -36,12 +36,6 @@ jobs: - name: Lint check run: pnpm lint - # 빌드 테스트 - - name: Build test - run: pnpm build - env: - DB_URI: ${{ secrets.DB_URI }} - # cd: # name: Continuous Deployment # needs: ci diff --git a/.gitignore b/.gitignore index 0c9bd8e..2a462f0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ public/rss.xml public/atom.xml public/feed.json .vscode/settings.json + +*/agents \ No newline at end of file diff --git a/app/api/drafts/route.ts b/app/api/drafts/route.ts new file mode 100644 index 0000000..82121f1 --- /dev/null +++ b/app/api/drafts/route.ts @@ -0,0 +1,160 @@ +import { getServerSession } from 'next-auth'; +import dbConnect from '@/app/lib/dbConnect'; +import CloudDraft from '@/app/models/CloudDraft'; + +// GET /api/drafts - 사용자의 클라우드 임시저장본 조회 +export async function GET(req: Request) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + // 30일 이상 지난 임시저장본이 있다면 삭제 + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await CloudDraft.deleteMany({ + createdAt: { $lt: thirtyDaysAgo }, + }); + + // 사용자의 임시저장본 조회 (최신순, 최대 3개) + const drafts = await CloudDraft.find({ userId: session.user.email }) + .sort({ createdAt: -1 }) + .limit(3) + .lean(); + + return Response.json({ success: true, drafts }, { status: 200 }); + } catch (error) { + console.error('Cloud draft fetch error:', error); + return Response.json({ error: 'Failed to fetch drafts' }, { status: 500 }); + } +} + +// POST /api/drafts - 클라우드 임시저장본 생성 또는 업데이트 +export async function POST(req: Request) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + const { + draftId, + title, + subTitle, + content, + tags, + imageUrls, + seriesId, + isPrivate, + } = await req.json(); + + if (!draftId) { + return Response.json({ error: 'draftId required' }, { status: 400 }); + } + + // 최소 검증: title 또는 content 필수 + if (!title && !content) { + return Response.json( + { error: 'Draft must have title or content' }, + { status: 400 } + ); + } + + const userId = session.user.email; + + // 기존 임시저장본 확인 + const existingDraft = await CloudDraft.findOne({ draftId, userId }); + + if (existingDraft) { + // 기존 임시저장본 업데이트 + const updatedDraft = await CloudDraft.findOneAndUpdate( + { draftId, userId }, + { + title, + subTitle, + content, + tags, + imageUrls, + seriesId, + isPrivate, + }, + { new: true, runValidators: true } + ); + + return Response.json( + { success: true, draft: updatedDraft }, + { status: 200 } + ); + } else { + // 새 임시저장본 생성 + // 3개 제한 확인 + const draftCount = await CloudDraft.countDocuments({ userId }); + + if (draftCount >= 3) { + // 가장 오래된 임시저장본 삭제 + const oldestDraft = await CloudDraft.findOne({ userId }) + .sort({ createdAt: 1 }) + .lean(); + + if (oldestDraft && !Array.isArray(oldestDraft)) { + await CloudDraft.deleteOne({ _id: oldestDraft._id }); + } + } + + // 새 임시저장본 생성 + const newDraft = await CloudDraft.create({ + draftId, + userId, + title, + subTitle, + content, + tags, + imageUrls, + seriesId, + isPrivate, + }); + + return Response.json({ success: true, draft: newDraft }, { status: 201 }); + } + } catch (error) { + console.error('Cloud draft save error:', error); + return Response.json({ error: 'Failed to save draft' }, { status: 500 }); + } +} + +// DELETE /api/drafts?draftId=xxx - 특정 클라우드 임시저장본 삭제 +export async function DELETE(req: Request) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + const { searchParams } = new URL(req.url); + const draftId = searchParams.get('draftId'); + + if (!draftId) { + return Response.json({ error: 'draftId required' }, { status: 400 }); + } + + const result = await CloudDraft.deleteOne({ + draftId, + userId: session.user.email, + }); + + if (result.deletedCount === 0) { + return Response.json({ error: 'Draft not found' }, { status: 404 }); + } + + return Response.json({ success: true }, { status: 200 }); + } catch (error) { + console.error('Cloud draft delete error:', error); + return Response.json({ error: 'Failed to delete draft' }, { status: 500 }); + } +} diff --git a/app/entities/post/write/AutoSyncToggle.tsx b/app/entities/post/write/AutoSyncToggle.tsx new file mode 100644 index 0000000..73099a8 --- /dev/null +++ b/app/entities/post/write/AutoSyncToggle.tsx @@ -0,0 +1,25 @@ +interface AutoSyncToggleProps { + enabled: boolean; + onToggle: (enabled: boolean) => void; +} + +const AutoSyncToggle = ({ enabled, onToggle }: AutoSyncToggleProps) => { + return ( + + ); +}; + +export default AutoSyncToggle; diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index d290dc7..a094832 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -3,23 +3,29 @@ import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; import dynamic from 'next/dynamic'; import { useSearchParams } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import ImageZoomOverlayContainer from '@/app/entities/common/Overlay/Image/ImageZoomOverlayContainer'; import Overlay from '@/app/entities/common/Overlay/Overlay'; +import DraftListOverlay from '@/app/entities/post/write/DraftListOverlay'; import PostMetadataForm from '@/app/entities/post/write/PostMetadataForm'; import PostWriteButtons from '@/app/entities/post/write/PostWriteButtons'; import UploadImageContainer from '@/app/entities/post/write/UploadImageContainer'; import CreateSeriesOverlayContainer from '@/app/entities/series/CreateSeriesOverlayContainer'; import { useBlockNavigate } from '@/app/hooks/common/useBlockNavigate'; import useOverlay from '@/app/hooks/common/useOverlay'; +import useAutoSync from '@/app/hooks/post/useAutoSync'; +import useCloudDraft from '@/app/hooks/post/useCloudDraft'; +import useDraft from '@/app/hooks/post/useDraft'; import usePost from '@/app/hooks/post/usePost'; import useTheme from '@/app/hooks/useTheme'; +import useToast from '@/app/hooks/useToast'; import { asideStyleRewrite, addDescriptionUnderImage, renderYoutubeEmbed, createImageClickHandler, } from '@/app/lib/utils/rehypeUtils'; +import { CloudDraft, DraftListItem, LocalDraft } from '@/app/types/Draft'; import LoadingSpinner from '../../common/Loading/LoadingSpinner'; const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); @@ -34,6 +40,7 @@ const BlogForm = () => { const slug = params.get('slug'); const isEditMode = Boolean(slug); const { theme } = useTheme(); + const toast = useToast(); const { formData, @@ -42,20 +49,50 @@ const BlogForm = () => { seriesList, uploadedImages, setUploadedImages, - overwriteDraft, saveToDraft, - clearDraftInStore, submitHandler, postBody, handleLinkCopy, } = usePost(slug || ''); + // 로컬 임시저장 훅 + const { draft, draftImages, clearDraft } = useDraft(); + + // 클라우드 임시저장 훅 + const { + cloudDrafts, + loading: cloudLoading, + autoSyncEnabled, + fetchCloudDrafts, + saveToCloud, + deleteCloudDraft, + toggleAutoSync, + getCurrentDraftId, + } = useCloudDraft(); + const [createSeriesOpen, setCreateSeriesOpen] = useState(false); + const [draftListOpen, setDraftListOpen] = useState(false); + const [draftListMode, setDraftListMode] = useState<'load' | 'delete'>('load'); const [selectedImage, setSelectedImage] = useState( null ); const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay(); + // 클라우드 임시저장본 조회 + useEffect(() => { + fetchCloudDrafts().catch((error) => { + console.error('Failed to fetch cloud drafts:', error); + }); + }, []); + + // 자동 동기화 설정 + useAutoSync({ + enabled: autoSyncEnabled, + intervalMs: 180000, // 3분 + onSync: handleSaveToCloud, + deps: [formData.title, formData.content, formData.subTitle, formData.tags], + }); + useBlockNavigate({ title: formData.title, content: formData.content || '' }); // 이미지 클릭 핸들러 생성 @@ -71,6 +108,116 @@ const BlogForm = () => { setFormData({ [field]: value }); }; + // 로컬 + 클라우드 임시저장본 병합 + const getAllDrafts = (): DraftListItem[] => { + const drafts: DraftListItem[] = []; + + // 로컬 임시저장본 추가 + if (draft) { + drafts.push({ + id: 'local', + title: draft.title || '', + date: new Date(), // 로컬은 타임스탬프가 없음 + source: 'local', + data: draft as LocalDraft, + }); + } + + // 클라우드 임시저장본 추가 + cloudDrafts.forEach((cd) => { + drafts.push({ + id: cd.draftId, + title: cd.title, + date: new Date(cd.updatedAt), + source: 'cloud', + data: cd as CloudDraft, + }); + }); + + // 최신순 정렬 + return drafts.sort((a, b) => b.date.getTime() - a.date.getTime()); + }; + + // 클라우드에 저장 + async function handleSaveToCloud() { + try { + await saveToCloud({ + title: formData.title, + subTitle: formData.subTitle, + content: formData.content || '', + tags: formData.tags, + imageUrls: uploadedImages, + seriesId: formData.seriesId, + isPrivate: formData.isPrivate, + }); + await fetchCloudDrafts(); + toast.success('클라우드에 저장되었습니다.'); + } catch (error) { + toast.error('클라우드 저장 실패'); + } + } + + // 임시저장본 불러오기 + const handleLoadDraft = (draft: DraftListItem) => { + const data = draft.data; + + if (draft.source === 'local') { + const localData = data as LocalDraft; + setFormData({ + title: localData.title || '', + subTitle: localData.subTitle || '', + content: localData.content, + seriesId: localData.seriesId || '', + tags: localData.tags || [], + isPrivate: localData.isPrivate || false, + }); + setUploadedImages(draftImages || []); + } else { + const cloudData = data as CloudDraft; + setFormData({ + title: cloudData.title || '', + subTitle: cloudData.subTitle || '', + content: cloudData.content, + seriesId: cloudData.seriesId || '', + tags: cloudData.tags || [], + isPrivate: cloudData.isPrivate || false, + }); + setUploadedImages(cloudData.imageUrls || []); + } + + setDraftListOpen(false); + toast.success('임시저장본을 불러왔습니다.'); + }; + + // 임시저장본 삭제 + const handleDeleteDraft = async ( + draftId: string, + source: 'local' | 'cloud' + ) => { + try { + if (source === 'local') { + clearDraft(); + } else { + await deleteCloudDraft(draftId); + } + toast.success('임시저장이 삭제되었습니다.'); + } catch (error) { + toast.error('삭제 실패'); + } + }; + + // 임시저장본 불러오기 오버레이 열기 + const openLoadDraftOverlay = () => { + setDraftListMode('load'); + setDraftListOpen(true); + }; + + // 임시저장 삭제 오버레이 열기 + const openDeleteDraftOverlay = () => { + setDraftListMode('delete'); + setDraftListOpen(true); + }; + return (

@@ -82,8 +229,10 @@ const BlogForm = () => { seriesLoading={uiState.seriesLoading} series={seriesList} onClickNewSeries={() => setCreateSeriesOpen(true)} - onClickOverwrite={overwriteDraft} - clearDraft={clearDraftInStore} + onClickOverwrite={openLoadDraftOverlay} + clearDraft={openDeleteDraftOverlay} + autoSyncEnabled={autoSyncEnabled} + onToggleAutoSync={toggleAutoSync} /> { /> + {/* Draft List Overlay */} + + + + { submitHandler={submitHandler} submitLoading={uiState.submitLoading} saveToDraft={saveToDraft} + saveToCloud={handleSaveToCloud} /> {isEditMode && uiState.seriesLoading && ( diff --git a/app/entities/post/write/DraftListOverlay.tsx b/app/entities/post/write/DraftListOverlay.tsx new file mode 100644 index 0000000..9326d07 --- /dev/null +++ b/app/entities/post/write/DraftListOverlay.tsx @@ -0,0 +1,123 @@ +import { DraftListItem } from '@/app/types/Draft'; + +interface DraftListOverlayProps { + drafts: DraftListItem[]; + onLoadDraft: (draft: DraftListItem) => void; + onDeleteDraft?: (draftId: string, source: 'local' | 'cloud') => void; + mode: 'load' | 'delete'; + currentDraftId?: string | null; +} + +const DraftListOverlay = ({ + drafts, + onLoadDraft, + onDeleteDraft, + mode, + currentDraftId, +}: DraftListOverlayProps) => { + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const handleItemClick = (draft: DraftListItem) => { + if (mode === 'load') { + onLoadDraft(draft); + } + }; + + const handleDelete = (draft: DraftListItem, e: React.MouseEvent) => { + e.stopPropagation(); + if (onDeleteDraft) { + if (confirm(`"${draft.title || '제목 없음'}" 임시저장을 삭제하시겠습니까?`)) { + onDeleteDraft(draft.id, draft.source); + } + } + }; + + return ( +
+

+ {mode === 'load' ? '임시저장본 불러오기' : '임시저장 삭제'} +

+ + {drafts.length === 0 ? ( +

+ 저장된 임시글이 없습니다. +

+ ) : ( +
+ {drafts.map((draft) => { + const isCurrentDraft = + draft.source === 'cloud' && draft.id === currentDraftId; + + return ( +
!isCurrentDraft && handleItemClick(draft)} + className={` + border rounded-lg p-4 transition-all + ${draft.source === 'local' ? 'border-blue-300' : 'border-green-300'} + ${isCurrentDraft + ? 'opacity-50 cursor-not-allowed' + : mode === 'load' + ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700' + : '' + } + `} + > +
+
+
+

+ {draft.title || '제목 없음'} +

+ {isCurrentDraft && ( + + 작성 중 + + )} +
+
+ + {formatDate(draft.date)} + + + {draft.source === 'local' ? '로컬' : '클라우드'} + +
+
+ + {mode === 'delete' && onDeleteDraft && ( + + )} +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default DraftListOverlay; diff --git a/app/entities/post/write/PostMetadataForm.tsx b/app/entities/post/write/PostMetadataForm.tsx index bfcb8e0..5e6480a 100644 --- a/app/entities/post/write/PostMetadataForm.tsx +++ b/app/entities/post/write/PostMetadataForm.tsx @@ -4,6 +4,7 @@ import { FaTrash } from 'react-icons/fa'; import { FaPlus } from 'react-icons/fa6'; import Select from '@/app/entities/common/Select'; import { Series } from '@/app/types/Series'; +import AutoSyncToggle from './AutoSyncToggle'; interface PostMetadataFormProps { onFieldChange: (field: string, value: string | boolean | string[]) => void; @@ -12,6 +13,8 @@ interface PostMetadataFormProps { onClickNewSeries: () => void; onClickOverwrite: () => void; clearDraft: () => void; + autoSyncEnabled: boolean; + onToggleAutoSync: (enabled: boolean) => void; formData: { title: string; subTitle: string; @@ -28,6 +31,8 @@ const PostMetadataForm = ({ onClickNewSeries, onClickOverwrite, clearDraft, + autoSyncEnabled, + onToggleAutoSync, formData, }: PostMetadataFormProps) => { const [tagInput, setTagInput] = useState(''); @@ -180,6 +185,10 @@ const PostMetadataForm = ({

+ +
+ +
); }; diff --git a/app/entities/post/write/PostWriteButtons.tsx b/app/entities/post/write/PostWriteButtons.tsx index bc81e9d..66568c5 100644 --- a/app/entities/post/write/PostWriteButtons.tsx +++ b/app/entities/post/write/PostWriteButtons.tsx @@ -1,4 +1,6 @@ import Link from 'next/link'; +import { FaHome } from 'react-icons/fa'; +import { FaChevronLeft, FaCloud, FaRocket } from 'react-icons/fa6'; import LoadingSpinner from '@/app/entities/common/Loading/LoadingSpinner'; import { PostBody } from '@/app/types/Post'; @@ -8,6 +10,7 @@ interface PostWriteButtonsProps { submitLoading: boolean; submitHandler: (postBody: PostBody) => void; saveToDraft: () => void; + saveToCloud: () => void; } const PostWriteButtons = ({ slug, @@ -15,30 +18,48 @@ const PostWriteButtons = ({ submitHandler, postBody, saveToDraft, + saveToCloud, }: PostWriteButtonsProps) => { - const buttonStyle = `font-bold py-2 px-4 rounded mr-2 disabled:bg-opacity-75 `; + const buttonStyle = `inline-flex justify-center items-center font-bold py-2 px-4 rounded disabled:bg-opacity-75 gap-2 `; return ( -
- - +
+ + +
diff --git a/app/hooks/post/useAutoSync.ts b/app/hooks/post/useAutoSync.ts new file mode 100644 index 0000000..cf09600 --- /dev/null +++ b/app/hooks/post/useAutoSync.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; + +interface AutoSyncConfig { + enabled: boolean; + intervalMs: number; + onSync: () => Promise; + deps: any[]; +} + +const useAutoSync = ({ enabled, intervalMs, onSync, deps }: AutoSyncConfig) => { + const intervalRef = useRef(null); + const lastSyncDataRef = useRef(''); + + useEffect(() => { + if (!enabled) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + intervalRef.current = setInterval(async () => { + const currentData = JSON.stringify(deps); + + if (currentData !== lastSyncDataRef.current) { + try { + await onSync(); + lastSyncDataRef.current = currentData; + console.log( + 'Auto-sync completed at', + new Date().toLocaleTimeString() + ); + } catch (error) { + console.error('Auto-sync failed:', error); + } + } + }, intervalMs); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [enabled, intervalMs, onSync, ...deps]); +}; + +export default useAutoSync; diff --git a/app/hooks/post/useCloudDraft.ts b/app/hooks/post/useCloudDraft.ts new file mode 100644 index 0000000..2de4462 --- /dev/null +++ b/app/hooks/post/useCloudDraft.ts @@ -0,0 +1,92 @@ +import axios from 'axios'; +import { useEffect, useRef, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { CloudDraft } from '@/app/types/Draft'; + +const useCloudDraft = () => { + const [cloudDrafts, setCloudDrafts] = useState([]); + const [loading, setLoading] = useState(false); + const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); + const draftIdRef = useRef(null); + + useEffect(() => { + if (!draftIdRef.current) { + draftIdRef.current = uuidv4(); + } + }, []); + + useEffect(() => { + const savedSetting = localStorage.getItem('cloudDraftAutoSync'); + setAutoSyncEnabled(savedSetting === 'true'); + }, []); + + const fetchCloudDrafts = async () => { + try { + setLoading(true); + const response = await axios.get('/api/drafts'); + if (response.data.success) { + setCloudDrafts(response.data.drafts); + } + } catch (error) { + console.error('Failed to fetch cloud drafts:', error); + throw error; + } finally { + setLoading(false); + } + }; + + const saveToCloud = async (draftData: { + title: string; + subTitle: string; + content: string; + tags: string[]; + imageUrls: string[]; + seriesId?: string; + isPrivate: boolean; + }) => { + if (!draftIdRef.current) { + throw new Error('Draft ID not initialized'); + } + + try { + const response = await axios.post('/api/drafts', { + draftId: draftIdRef.current, + ...draftData, + }); + return response.data; + } catch (error) { + console.error('Failed to save draft to cloud:', error); + throw error; + } + }; + + const deleteCloudDraft = async (draftId: string) => { + try { + await axios.delete(`/api/drafts?draftId=${draftId}`); + setCloudDrafts((prev) => prev.filter((d) => d.draftId !== draftId)); + } catch (error) { + console.error('Failed to delete cloud draft:', error); + throw error; + } + }; + + const toggleAutoSync = (enabled: boolean) => { + setAutoSyncEnabled(enabled); + localStorage.setItem('cloudDraftAutoSync', enabled.toString()); + }; + + const getCurrentDraftId = () => draftIdRef.current; + + return { + cloudDrafts, + loading, + autoSyncEnabled, + fetchCloudDrafts, + saveToCloud, + deleteCloudDraft, + toggleAutoSync, + getCurrentDraftId, + }; +}; + +export default useCloudDraft; diff --git a/app/models/CloudDraft.ts b/app/models/CloudDraft.ts new file mode 100644 index 0000000..ae79faf --- /dev/null +++ b/app/models/CloudDraft.ts @@ -0,0 +1,56 @@ +import { Schema, model, models } from 'mongoose'; + +const cloudDraftSchema = new Schema( + { + draftId: { + type: String, + required: true, + unique: true, + index: true, + }, + userId: { + type: String, + required: true, + index: true, + }, + title: { + type: String, + default: '', + }, + subTitle: { + type: String, + default: '', + }, + content: { + type: String, + default: '', + }, + tags: { + type: [String], + default: [], + }, + imageUrls: { + type: [String], + default: [], + }, + seriesId: { + type: String, + default: '', + }, + isPrivate: { + type: Boolean, + default: false, + }, + }, + { + timestamps: true, // createdAt, updatedAt 자동 생성 + } +); + +// 효율적인 쿼리를 위한 복합 인덱스 +cloudDraftSchema.index({ userId: 1, createdAt: -1 }); + +const CloudDraft = + models.CloudDraft || model('CloudDraft', cloudDraftSchema); + +export default CloudDraft; diff --git a/app/types/Draft.ts b/app/types/Draft.ts new file mode 100644 index 0000000..2adb01f --- /dev/null +++ b/app/types/Draft.ts @@ -0,0 +1,31 @@ +export interface CloudDraft { + _id: string; + draftId: string; + userId: string; + title: string; + subTitle: string; + content: string; + tags: string[]; + imageUrls: string[]; + seriesId?: string; + isPrivate: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface LocalDraft { + title: string; + subTitle: string; + content?: string; + tags: string[]; + seriesId?: string; + isPrivate: boolean; +} + +export interface DraftListItem { + id: string; // draftId for cloud, 'local' for local + title: string; + date: Date; + source: 'local' | 'cloud'; + data: LocalDraft | CloudDraft; +} diff --git a/package.json b/package.json index 7d905aa..11183d3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-lottie-player": "^2.1.0", "resend": "^6.5.2", "sharp": "^0.33.5", + "uuid": "^13.0.0", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae0005..e5edb6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: sharp: specifier: ^0.33.5 version: 0.33.5 + uuid: + specifier: ^13.0.0 + version: 13.0.0 zustand: specifier: ^5.0.3 version: 5.0.3(@types/react@18.3.20)(react@18.3.1) @@ -3701,6 +3704,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -8471,6 +8478,8 @@ snapshots: uuid@10.0.0: {} + uuid@13.0.0: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {}