Files
tasteby/frontend/src/lib/api.ts
joungmin cdee37e341 UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:49:16 +09:00

507 lines
13 KiB
TypeScript

const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
const TOKEN_KEY = "tasteby_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken() {
localStorage.removeItem(TOKEN_KEY);
}
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((init?.headers as Record<string, string>) || {}),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers,
});
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
return res.json();
}
export interface Restaurant {
id: string;
name: string;
address: string | null;
region: string | null;
latitude: number;
longitude: number;
cuisine_type: string | null;
price_range: string | null;
google_place_id: string | null;
tabling_url: string | null;
catchtable_url: string | null;
business_status: string | null;
rating: number | null;
rating_count: number | null;
phone: string | null;
website: string | null;
channels?: string[];
foods_mentioned?: string[];
}
export interface VideoLink {
video_id: string;
title: string;
url: string;
published_at: string | null;
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
channel_name: string | null;
channel_id: string | null;
}
export interface Channel {
id: string;
channel_id: string;
channel_name: string;
title_filter: string | null;
video_count: number;
last_scanned_at: string | null;
}
export interface Video {
id: string;
video_id: string;
title: string;
url: string;
status: string;
published_at: string | null;
channel_name: string;
has_transcript: boolean;
has_llm: boolean;
restaurant_count: number;
matched_count: number;
}
export interface VideoRestaurant {
restaurant_id: string;
name: string;
address: string | null;
cuisine_type: string | null;
price_range: string | null;
region: string | null;
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
google_place_id: string | null;
has_location: boolean;
}
export interface VideoDetail extends Video {
transcript: string | null;
restaurants: VideoRestaurant[];
}
export interface User {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
is_admin?: boolean;
}
export interface Review {
id: string;
user_id: string;
rating: number;
review_text: string | null;
visited_at: string | null;
created_at: string;
user_nickname: string | null;
user_avatar_url: string | null;
}
export interface DaemonConfig {
scan_enabled: boolean;
scan_interval_min: number;
process_enabled: boolean;
process_interval_min: number;
process_limit: number;
last_scan_at: string | null;
last_process_at: string | null;
updated_at: string | null;
}
export interface ReviewsResponse {
reviews: Review[];
avg_rating: number | null;
review_count: number;
}
export const api = {
getRestaurants(params?: {
cuisine?: string;
region?: string;
channel?: string;
limit?: number;
offset?: number;
}) {
const sp = new URLSearchParams();
if (params?.cuisine) sp.set("cuisine", params.cuisine);
if (params?.region) sp.set("region", params.region);
if (params?.channel) sp.set("channel", params.channel);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
},
getRestaurant(id: string) {
return fetchApi<Restaurant>(`/api/restaurants/${id}`);
},
updateRestaurant(id: string, data: Partial<Restaurant>) {
return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteRestaurant(id: string) {
return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, {
method: "DELETE",
});
},
getRestaurantVideos(id: string) {
return fetchApi<VideoLink[]>(`/api/restaurants/${id}/videos`);
},
search(q: string, mode: "keyword" | "semantic" | "hybrid" = "keyword") {
return fetchApi<Restaurant[]>(
`/api/search?q=${encodeURIComponent(q)}&mode=${mode}`
);
},
getChannels() {
return fetchApi<Channel[]>("/api/channels");
},
loginWithGoogle(idToken: string) {
return fetchApi<{ access_token: string; user: User }>("/api/auth/google", {
method: "POST",
body: JSON.stringify({ id_token: idToken }),
});
},
getMe() {
return fetchApi<User>("/api/auth/me");
},
getFavoriteStatus(restaurantId: string) {
return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`);
},
toggleFavorite(restaurantId: string) {
return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`, {
method: "POST",
});
},
getMyFavorites() {
return fetchApi<Restaurant[]>("/api/users/me/favorites");
},
getReviews(restaurantId: string) {
return fetchApi<ReviewsResponse>(`/api/restaurants/${restaurantId}/reviews`);
},
createReview(
restaurantId: string,
data: { rating: number; review_text?: string; visited_at?: string }
) {
return fetchApi<Review>(`/api/restaurants/${restaurantId}/reviews`, {
method: "POST",
body: JSON.stringify(data),
});
},
updateReview(
reviewId: string,
data: { rating: number; review_text?: string; visited_at?: string }
) {
return fetchApi<Review>(`/api/reviews/${reviewId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteReview(reviewId: string) {
return fetchApi<void>(`/api/reviews/${reviewId}`, {
method: "DELETE",
});
},
getMyReviews() {
return fetchApi<(Review & { restaurant_id: string; restaurant_name: string | null })[]>(
"/api/users/me/reviews?limit=100"
);
},
// Stats
recordVisit() {
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
},
getVisits() {
return fetchApi<{ today: number; total: number }>("/api/stats/visits");
},
// Admin - Users
getAdminUsers(params?: { limit?: number; offset?: number }) {
const sp = new URLSearchParams();
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<{
users: {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
provider: string | null;
created_at: string | null;
favorite_count: number;
review_count: number;
}[];
total: number;
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
},
getAdminUserFavorites(userId: string) {
return fetchApi<
{
id: string;
name: string;
address: string | null;
region: string | null;
cuisine_type: string | null;
rating: number | null;
business_status: string | null;
created_at: string | null;
}[]
>(`/api/admin/users/${userId}/favorites`);
},
getAdminUserReviews(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number;
review_text: string | null;
visited_at: string | null;
created_at: string | null;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/reviews`);
},
// Admin
addChannel(channelId: string, channelName: string, titleFilter?: string) {
return fetchApi<{ id: string; channel_id: string }>("/api/channels", {
method: "POST",
body: JSON.stringify({
channel_id: channelId,
channel_name: channelName,
title_filter: titleFilter || null,
}),
});
},
searchTabling(restaurantId: string) {
return fetchApi<{ title: string; url: string }[]>(
`/api/restaurants/${restaurantId}/tabling-search`
);
},
setTablingUrl(restaurantId: string, tablingUrl: string) {
return fetchApi<{ ok: boolean }>(
`/api/restaurants/${restaurantId}/tabling-url`,
{ method: "PUT", body: JSON.stringify({ tabling_url: tablingUrl }) }
);
},
searchCatchtable(restaurantId: string) {
return fetchApi<{ title: string; url: string }[]>(
`/api/restaurants/${restaurantId}/catchtable-search`
);
},
setCatchtableUrl(restaurantId: string, catchtableUrl: string) {
return fetchApi<{ ok: boolean }>(
`/api/restaurants/${restaurantId}/catchtable-url`,
{ method: "PUT", body: JSON.stringify({ catchtable_url: catchtableUrl }) }
);
},
deleteChannel(channelId: string) {
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
method: "DELETE",
});
},
scanChannel(channelId: string, full: boolean = false) {
return fetchApi<{ total_fetched: number; new_videos: number }>(
`/api/channels/${channelId}/scan${full ? "?full=true" : ""}`,
{ method: "POST" }
);
},
getVideos(params?: { status?: string; limit?: number; offset?: number }) {
const sp = new URLSearchParams();
if (params?.status) sp.set("status", params.status);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<Video[]>(`/api/videos${qs ? `?${qs}` : ""}`);
},
getVideoDetail(videoDbId: string) {
return fetchApi<VideoDetail>(`/api/videos/${videoDbId}`);
},
skipVideo(videoDbId: string) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}/skip`, {
method: "POST",
});
},
deleteVideo(videoDbId: string) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, {
method: "DELETE",
});
},
getExtractPrompt() {
return fetchApi<{ prompt: string }>("/api/videos/extract/prompt");
},
extractRestaurants(videoDbId: string, customPrompt?: string) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/videos/${videoDbId}/extract`,
{
method: "POST",
body: JSON.stringify(customPrompt ? { prompt: customPrompt } : {}),
}
);
},
fetchTranscript(videoDbId: string, mode: "auto" | "manual" | "generated" = "auto") {
return fetchApi<{ ok: boolean; length: number; source: string }>(
`/api/videos/${videoDbId}/fetch-transcript?mode=${mode}`,
{ method: "POST" }
);
},
uploadTranscript(videoDbId: string, text: string, source: string = "browser") {
return fetchApi<{ ok: boolean; length: number; source: string }>(
`/api/videos/${videoDbId}/upload-transcript`,
{ method: "POST", body: JSON.stringify({ text, source }) }
);
},
triggerProcessing(limit: number = 5) {
return fetchApi<{ restaurants_extracted: number }>(
`/api/videos/process?limit=${limit}`,
{ method: "POST" }
);
},
getBulkExtractPending() {
return fetchApi<{ count: number; videos: { id: string; title: string }[] }>(
"/api/videos/bulk-extract/pending"
);
},
getBulkTranscriptPending() {
return fetchApi<{ count: number; videos: { id: string; title: string }[] }>(
"/api/videos/bulk-transcript/pending"
);
},
updateVideo(videoDbId: string, data: { title: string }) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
addManualRestaurant(
videoDbId: string,
data: {
name: string;
address?: string;
region?: string;
cuisine_type?: string;
price_range?: string;
foods_mentioned?: string[];
evaluation?: string;
guests?: string[];
}
) {
return fetchApi<{ ok: boolean; restaurant_id: string; link_id: string }>(
`/api/videos/${videoDbId}/restaurants/manual`,
{ method: "POST", body: JSON.stringify(data) }
);
},
deleteVideoRestaurant(videoDbId: string, restaurantId: string) {
return fetchApi<{ ok: boolean }>(
`/api/videos/${videoDbId}/restaurants/${restaurantId}`,
{ method: "DELETE" }
);
},
updateVideoRestaurant(
videoDbId: string,
restaurantId: string,
data: Partial<VideoRestaurant>
) {
return fetchApi<{ ok: boolean }>(
`/api/videos/${videoDbId}/restaurants/${restaurantId}`,
{ method: "PUT", body: JSON.stringify(data) }
);
},
// Daemon config
getDaemonConfig() {
return fetchApi<DaemonConfig>("/api/daemon/config");
},
updateDaemonConfig(data: Partial<DaemonConfig>) {
return fetchApi<{ ok: boolean }>("/api/daemon/config", {
method: "PUT",
body: JSON.stringify(data),
});
},
runDaemonScan() {
return fetchApi<{ ok: boolean; new_videos: number }>("/api/daemon/run/scan", {
method: "POST",
});
},
runDaemonProcess(limit: number = 10) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/daemon/run/process?limit=${limit}`,
{ method: "POST" }
);
},
};