feat(inbox): 백엔드 API 연동하여 생각(메모) 영속화 적용

This commit is contained in:
2026-03-09 23:52:15 +09:00
parent 986b9ba94b
commit 1c55f74132
2 changed files with 148 additions and 82 deletions

View File

@@ -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<RecentThought[]>(() => readStoredThoughts());
const [thoughts, setThoughts] = useState<RecentThought[]>([]);
useEffect(() => {
persistThoughts(thoughts);
}, [thoughts]);
let mounted = true;
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== THOUGHT_INBOX_STORAGE_KEY) {
return;
}
setThoughts(readStoredThoughts());
};
window.addEventListener('storage', handleStorage);
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;
}, []);

View File

@@ -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<InboxThoughtResponse[]> => {
return apiClient<InboxThoughtResponse[]>('api/v1/inbox/thoughts', {
method: 'GET',
});
},
addThought: async (payload: CreateInboxThoughtRequest): Promise<InboxThoughtResponse> => {
return apiClient<InboxThoughtResponse>('api/v1/inbox/thoughts', {
method: 'POST',
body: JSON.stringify(payload),
});
},
updateThoughtComplete: async (
thoughtId: string,
payload: UpdateInboxThoughtRequest
): Promise<InboxThoughtResponse> => {
return apiClient<InboxThoughtResponse>(`api/v1/inbox/thoughts/${thoughtId}/complete`, {
method: 'PUT',
body: JSON.stringify(payload),
});
},
deleteThought: async (thoughtId: string): Promise<void> => {
return apiClient<void>(`api/v1/inbox/thoughts/${thoughtId}`, {
method: 'DELETE',
expectNoContent: true,
});
},
clearThoughts: async (): Promise<void> => {
return apiClient<void>('api/v1/inbox/thoughts', {
method: 'DELETE',
expectNoContent: true,
});
},
};