- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가 - 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능) - 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가 - 채널 필터 시 해당 채널만 마커/범례 표시 - 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴 - 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용) - 테이블링/캐치테이블 버튼 UI 차별화 - Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507 lines
13 KiB
TypeScript
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" }
|
|
);
|
|
},
|
|
};
|