diff --git a/src/entities/session/model/useThoughtInbox.ts b/src/entities/session/model/useThoughtInbox.ts index eeb3352..33bc084 100644 --- a/src/entities/session/model/useThoughtInbox.ts +++ b/src/entities/session/model/useThoughtInbox.ts @@ -1,94 +1,37 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { copy } from '@/shared/i18n'; import type { RecentThought } from './types'; +import { inboxApi } from '@/features/inbox/api/inboxApi'; -const THOUGHT_INBOX_STORAGE_KEY = 'viberoom:thought-inbox:v1'; const MAX_THOUGHT_INBOX_ITEMS = 40; -const readStoredThoughts = () => { - if (typeof window === 'undefined') { - return []; - } - - const raw = window.localStorage.getItem(THOUGHT_INBOX_STORAGE_KEY); - - if (!raw) { - return []; - } - - try { - const parsed = JSON.parse(raw); - - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.flatMap((thought): RecentThought[] => { - if (!thought || typeof thought !== 'object') { - return []; - } - - const sceneName = - typeof thought.sceneName === 'string' - ? thought.sceneName - : typeof thought.roomName === 'string' - ? thought.roomName - : null; - - if ( - typeof thought.id !== 'string' || - typeof thought.text !== 'string' || - typeof thought.capturedAt !== 'string' || - !sceneName || - (typeof thought.isCompleted !== 'undefined' && typeof thought.isCompleted !== 'boolean') - ) { - return []; - } - - return [ - { - id: thought.id, - text: thought.text, - sceneName, - capturedAt: thought.capturedAt, - isCompleted: thought.isCompleted, - }, - ]; - }); - } catch { - return []; - } -}; - -const persistThoughts = (thoughts: RecentThought[]) => { - if (typeof window === 'undefined') { - return; - } - - window.localStorage.setItem(THOUGHT_INBOX_STORAGE_KEY, JSON.stringify(thoughts)); -}; - export const useThoughtInbox = () => { - const [thoughts, setThoughts] = useState(() => readStoredThoughts()); + const [thoughts, setThoughts] = useState([]); useEffect(() => { - persistThoughts(thoughts); - }, [thoughts]); - - useEffect(() => { - const handleStorage = (event: StorageEvent) => { - if (event.key !== THOUGHT_INBOX_STORAGE_KEY) { - return; - } - - setThoughts(readStoredThoughts()); - }; - - window.addEventListener('storage', handleStorage); + let mounted = true; + + inboxApi.getThoughts() + .then((data) => { + if (!mounted) return; + setThoughts( + data.map((d) => ({ + id: d.id, + text: d.text, + sceneName: d.sceneName, + isCompleted: d.isCompleted, + capturedAt: copy.session.justNow, + })) + ); + }) + .catch((err) => { + console.error('Failed to load inbox thoughts:', err); + }); return () => { - window.removeEventListener('storage', handleStorage); + mounted = false; }; }, []); @@ -99,20 +42,32 @@ export const useThoughtInbox = () => { return null; } + const tempId = `thought-temp-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const thought: RecentThought = { - id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + id: tempId, text: trimmedText, sceneName, - capturedAt: '방금 전', + capturedAt: copy.session.justNow, isCompleted: false, }; setThoughts((current) => { const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS); - return next; }); + inboxApi.addThought({ text: trimmedText, sceneName }) + .then((res) => { + setThoughts((current) => + current.map((t) => (t.id === tempId ? { ...t, id: res.id } : t)) + ); + }) + .catch((err) => { + console.error('Failed to add thought:', err); + setThoughts((current) => current.filter((t) => t.id !== tempId)); + }); + return thought; }, []); @@ -131,6 +86,12 @@ export const useThoughtInbox = () => { return current.filter((thought) => thought.id !== thoughtId); }); + if (removedThought && !thoughtId.startsWith('thought-temp-')) { + inboxApi.deleteThought(thoughtId).catch((err) => { + console.error('Failed to delete thought:', err); + }); + } + return removedThought; }, []); @@ -142,6 +103,10 @@ export const useThoughtInbox = () => { return []; }); + inboxApi.clearThoughts().catch((err) => { + console.error('Failed to clear thoughts:', err); + }); + return snapshot; }, []); @@ -150,12 +115,44 @@ export const useThoughtInbox = () => { const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id); return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS); }); + + inboxApi.addThought({ text: thought.text, sceneName: thought.sceneName }) + .then((res) => { + setThoughts((current) => + current.map((t) => (t.id === thought.id ? { ...t, id: res.id } : t)) + ); + }) + .catch((err) => { + console.error('Failed to restore thought:', err); + }); }, []); const restoreThoughts = useCallback((snapshot: RecentThought[]) => { setThoughts(() => { return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS); }); + + // Fire and forget bulk restore as we don't have a bulk API + Promise.all( + snapshot.map((t) => inboxApi.addThought({ text: t.text, sceneName: t.sceneName })) + ) + .then(() => { + return inboxApi.getThoughts(); + }) + .then((data) => { + setThoughts( + data.map((d) => ({ + id: d.id, + text: d.text, + sceneName: d.sceneName, + isCompleted: d.isCompleted, + capturedAt: copy.session.justNow, + })) + ); + }) + .catch((err) => { + console.error('Failed to restore thoughts:', err); + }); }, []); const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => { @@ -178,6 +175,18 @@ export const useThoughtInbox = () => { return next; }); + if (!thoughtId.startsWith('thought-temp-')) { + inboxApi.updateThoughtComplete(thoughtId, { isCompleted }).catch((err) => { + console.error('Failed to update thought completion:', err); + // Optionally revert state on failure + if (previousThought) { + setThoughts((current) => { + return current.map(t => t.id === thoughtId ? { ...t, isCompleted: !isCompleted } : t); + }); + } + }); + } + return previousThought; }, []); diff --git a/src/features/inbox/api/inboxApi.ts b/src/features/inbox/api/inboxApi.ts new file mode 100644 index 0000000..60478e0 --- /dev/null +++ b/src/features/inbox/api/inboxApi.ts @@ -0,0 +1,57 @@ +import { apiClient } from '@/shared/lib/apiClient'; + +export interface InboxThoughtResponse { + id: string; + text: string; + sceneName: string; + isCompleted: boolean; + capturedAt: string; +} + +export interface CreateInboxThoughtRequest { + text: string; + sceneName: string; +} + +export interface UpdateInboxThoughtRequest { + isCompleted: boolean; +} + +export const inboxApi = { + getThoughts: async (): Promise => { + return apiClient('api/v1/inbox/thoughts', { + method: 'GET', + }); + }, + + addThought: async (payload: CreateInboxThoughtRequest): Promise => { + return apiClient('api/v1/inbox/thoughts', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + + updateThoughtComplete: async ( + thoughtId: string, + payload: UpdateInboxThoughtRequest + ): Promise => { + return apiClient(`api/v1/inbox/thoughts/${thoughtId}/complete`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + }, + + deleteThought: async (thoughtId: string): Promise => { + return apiClient(`api/v1/inbox/thoughts/${thoughtId}`, { + method: 'DELETE', + expectNoContent: true, + }); + }, + + clearThoughts: async (): Promise => { + return apiClient('api/v1/inbox/thoughts', { + method: 'DELETE', + expectNoContent: true, + }); + }, +};