Add admin features, responsive UI, user reviews, visit stats, and channel-colored markers
- Admin: video management with Google Maps match status, manual restaurant mapping, restaurant remap on name change - Admin: user management tab with favorites/reviews detail - Admin: channel deletion fix for IDs with slashes - Frontend: responsive mobile layout (map top, list bottom, 2-row header) - Frontend: channel-colored map markers with legend - Frontend: my reviews list, favorites toggle, visit counter overlay - Frontend: force light mode for dark theme devices - Backend: visit tracking (site_visits table), user reviews endpoint - Backend: bulk transcript/extract streaming, geocode key fixes - Nginx config for production deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
const TOKEN_KEY = "tasteby_token";
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface Restaurant {
|
||||
cuisine_type: string | null;
|
||||
price_range: string | null;
|
||||
google_place_id: string | null;
|
||||
business_status: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
export interface VideoLink {
|
||||
@@ -52,12 +58,48 @@ export interface VideoLink {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -88,12 +130,14 @@ 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();
|
||||
@@ -104,6 +148,19 @@ export const api = {
|
||||
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`);
|
||||
},
|
||||
@@ -129,6 +186,20 @@ export const api = {
|
||||
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`);
|
||||
},
|
||||
@@ -158,4 +229,203 @@ export const api = {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
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" }
|
||||
);
|
||||
},
|
||||
|
||||
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) }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user