Initial commit: Tasteby - YouTube restaurant map service

Backend (FastAPI + Oracle ADB), Frontend (Next.js), daemon worker.
Features: channel/video/restaurant management, semantic search,
Google OAuth, user reviews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-06 13:47:19 +09:00
commit 36bec10bd0
54 changed files with 9727 additions and 0 deletions

161
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,161 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
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;
}
export interface VideoLink {
video_id: string;
title: string;
url: string;
published_at: string | null;
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
}
export interface Channel {
id: string;
channel_id: string;
channel_name: string;
}
export interface User {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
}
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 ReviewsResponse {
reviews: Review[];
avg_rating: number | null;
review_count: number;
}
export const api = {
getRestaurants(params?: {
cuisine?: string;
region?: 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?.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}`);
},
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");
},
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",
});
},
};

View File

@@ -0,0 +1,75 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import type { ReactNode } from "react";
import { api, setToken, removeToken, getToken } from "@/lib/api";
import type { User } from "@/lib/api";
interface AuthContextValue {
user: User | null;
token: string | null;
isLoading: boolean;
login: (idToken: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
token: null,
isLoading: true,
login: async () => {},
logout: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setTokenState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// On mount, check for existing token
useEffect(() => {
const existing = getToken();
if (existing) {
setTokenState(existing);
api
.getMe()
.then(setUser)
.catch(() => {
removeToken();
setTokenState(null);
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
const login = useCallback(async (idToken: string) => {
const result = await api.loginWithGoogle(idToken);
setToken(result.access_token);
setTokenState(result.access_token);
setUser(result.user);
}, []);
const logout = useCallback(() => {
removeToken();
setTokenState(null);
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, token, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}