feat(inbox): 백엔드 API 연동하여 생각(메모) 영속화 적용
This commit is contained in:
@@ -1,94 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import type { RecentThought } from './types';
|
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 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 = () => {
|
export const useThoughtInbox = () => {
|
||||||
const [thoughts, setThoughts] = useState<RecentThought[]>(() => readStoredThoughts());
|
const [thoughts, setThoughts] = useState<RecentThought[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
persistThoughts(thoughts);
|
let mounted = true;
|
||||||
}, [thoughts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
inboxApi.getThoughts()
|
||||||
const handleStorage = (event: StorageEvent) => {
|
.then((data) => {
|
||||||
if (event.key !== THOUGHT_INBOX_STORAGE_KEY) {
|
if (!mounted) return;
|
||||||
return;
|
setThoughts(
|
||||||
}
|
data.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
setThoughts(readStoredThoughts());
|
text: d.text,
|
||||||
};
|
sceneName: d.sceneName,
|
||||||
|
isCompleted: d.isCompleted,
|
||||||
window.addEventListener('storage', handleStorage);
|
capturedAt: copy.session.justNow,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load inbox thoughts:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('storage', handleStorage);
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -99,20 +42,32 @@ export const useThoughtInbox = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tempId = `thought-temp-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
|
||||||
const thought: RecentThought = {
|
const thought: RecentThought = {
|
||||||
id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
id: tempId,
|
||||||
text: trimmedText,
|
text: trimmedText,
|
||||||
sceneName,
|
sceneName,
|
||||||
capturedAt: '방금 전',
|
capturedAt: copy.session.justNow,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
setThoughts((current) => {
|
setThoughts((current) => {
|
||||||
const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
|
|
||||||
return next;
|
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;
|
return thought;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -131,6 +86,12 @@ export const useThoughtInbox = () => {
|
|||||||
return current.filter((thought) => thought.id !== thoughtId);
|
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;
|
return removedThought;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -142,6 +103,10 @@ export const useThoughtInbox = () => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inboxApi.clearThoughts().catch((err) => {
|
||||||
|
console.error('Failed to clear thoughts:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -150,12 +115,44 @@ export const useThoughtInbox = () => {
|
|||||||
const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
|
const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
|
||||||
return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
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[]) => {
|
const restoreThoughts = useCallback((snapshot: RecentThought[]) => {
|
||||||
setThoughts(() => {
|
setThoughts(() => {
|
||||||
return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
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) => {
|
const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => {
|
||||||
@@ -178,6 +175,18 @@ export const useThoughtInbox = () => {
|
|||||||
return next;
|
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;
|
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