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:
161
frontend/src/lib/api.ts
Normal file
161
frontend/src/lib/api.ts
Normal 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",
|
||||
});
|
||||
},
|
||||
};
|
||||
75
frontend/src/lib/auth-context.tsx
Normal file
75
frontend/src/lib/auth-context.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user