feat(inbox): 백엔드 API 연동하여 생각(메모) 영속화 적용
This commit is contained in:
@@ -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;
|
||||
}, []);
|
||||
|
||||
|
||||
57
src/features/inbox/api/inboxApi.ts
Normal file
57
src/features/inbox/api/inboxApi.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user