| 사용자 |
이메일 |
+ 관리자 |
찜 |
리뷰 |
메모 |
@@ -2282,6 +2359,27 @@ function UsersPanel() {
{u.email || "-"} |
+
+
+ |
{u.favorite_count > 0 ? (
diff --git a/frontend/src/lib/admin-utils.ts b/frontend/src/lib/admin-utils.ts
new file mode 100644
index 0000000..b88e56f
--- /dev/null
+++ b/frontend/src/lib/admin-utils.ts
@@ -0,0 +1,52 @@
+// #304 어드민 페이지 공통 유틸.
+// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복.
+
+const TOKEN_KEY = "tasteby_token";
+
+export function getAdminToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(TOKEN_KEY);
+}
+
+export function authHeaders(): Record {
+ const token = getAdminToken();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
+
+/**
+ * SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출.
+ * 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트.
+ * 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip.
+ */
+export async function consumeSseStream(
+ response: Response,
+ onEvent: (event: unknown) => void,
+ onError?: (err: unknown) => void,
+): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) return;
+ const decoder = new TextDecoder();
+ let buffer = "";
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("data:")) continue;
+ const payload = trimmed.slice(5).trim();
+ if (!payload) continue;
+ try {
+ onEvent(JSON.parse(payload));
+ } catch {
+ // 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음
+ }
+ }
+ }
+ } catch (err) {
+ onError?.(err);
+ }
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 2539d7c..131c24f 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -51,6 +51,10 @@ export interface Restaurant {
website: string | null;
channels?: string[];
foods_mentioned?: string[];
+ // #322 LLM 검증
+ hidden?: boolean;
+ hidden_reason?: string | null;
+ verified_at?: string | null;
}
export interface VideoLink {
@@ -310,6 +314,7 @@ export const api = {
email: string | null;
nickname: string | null;
avatar_url: string | null;
+ is_admin: boolean;
provider: string | null;
created_at: string | null;
favorite_count: number;
@@ -320,6 +325,14 @@ export const api = {
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
},
+ updateAdminUserAdmin(userId: string, admin: boolean) {
+ return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ admin }),
+ });
+ },
+
getAdminUserFavorites(userId: string) {
return fetchApi<
{
@@ -567,4 +580,30 @@ export const api = {
{ method: "POST" }
);
},
+
+ // #322 — LLM 검증 어드민 API
+ getVerifyPending() {
+ return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
+ },
+ verifyAll(batchSize: number = 10) {
+ return fetchApi<{ processed: number }>(
+ `/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
+ { method: "POST" }
+ );
+ },
+ verifyOne(id: string) {
+ return fetchApi<{ success: boolean; id: string }>(
+ `/api/admin/restaurants/${id}/verify`,
+ { method: "POST" }
+ );
+ },
+ setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
+ return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
+ `/api/admin/restaurants/${id}/hidden`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ hidden, reason }),
+ }
+ );
+ },
};
|