UX improvements: mobile bottom sheet, cuisine taxonomy, search enhancements
- Add BottomSheet component for Google Maps-style restaurant detail on mobile (3-snap drag: 40%/55%/92%, velocity-based close, backdrop overlay) - Mobile map mode now full-screen with bottom sheet overlay for details - Collapsible filter panel on mobile with active filter badge count - Standardized cuisine taxonomy (46 categories: 한식|국밥, 일식|스시 etc.) with LLM remap endpoint and admin UI button - Enhanced search: keyword search now includes foods_mentioned + video title - Search results include channels array for frontend filtering - Channel filter moved to frontend filteredRestaurants (not API-level) - LLM extraction prompt updated for pipe-delimited region + cuisine taxonomy - Vector rebuild endpoint with rich JSON chunks per restaurant - Geolocation-based auto region selection on page load - Desktop filters split into two clean rows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
98
backend/api/routes/daemon.py
Normal file
98
backend/api/routes/daemon.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Daemon config & manual trigger API routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from api.deps import get_admin_user
|
||||||
|
from core.db import conn
|
||||||
|
from core import cache
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonConfigUpdate(BaseModel):
|
||||||
|
scan_enabled: bool | None = None
|
||||||
|
scan_interval_min: int | None = None
|
||||||
|
process_enabled: bool | None = None
|
||||||
|
process_interval_min: int | None = None
|
||||||
|
process_limit: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
def get_config():
|
||||||
|
"""Get daemon config (read-only for all authenticated users)."""
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT scan_enabled, scan_interval_min, process_enabled, process_interval_min, "
|
||||||
|
"process_limit, last_scan_at, last_process_at, updated_at "
|
||||||
|
"FROM daemon_config WHERE id = 1"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"scan_enabled": bool(row[0]),
|
||||||
|
"scan_interval_min": row[1],
|
||||||
|
"process_enabled": bool(row[2]),
|
||||||
|
"process_interval_min": row[3],
|
||||||
|
"process_limit": row[4],
|
||||||
|
"last_scan_at": str(row[5]) if row[5] else None,
|
||||||
|
"last_process_at": str(row[6]) if row[6] else None,
|
||||||
|
"updated_at": str(row[7]) if row[7] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
def update_config(body: DaemonConfigUpdate, _admin: dict = Depends(get_admin_user)):
|
||||||
|
"""Update daemon schedule config (admin only)."""
|
||||||
|
sets = []
|
||||||
|
params: dict = {}
|
||||||
|
if body.scan_enabled is not None:
|
||||||
|
sets.append("scan_enabled = :se")
|
||||||
|
params["se"] = 1 if body.scan_enabled else 0
|
||||||
|
if body.scan_interval_min is not None:
|
||||||
|
sets.append("scan_interval_min = :si")
|
||||||
|
params["si"] = body.scan_interval_min
|
||||||
|
if body.process_enabled is not None:
|
||||||
|
sets.append("process_enabled = :pe")
|
||||||
|
params["pe"] = 1 if body.process_enabled else 0
|
||||||
|
if body.process_interval_min is not None:
|
||||||
|
sets.append("process_interval_min = :pi")
|
||||||
|
params["pi"] = body.process_interval_min
|
||||||
|
if body.process_limit is not None:
|
||||||
|
sets.append("process_limit = :pl")
|
||||||
|
params["pl"] = body.process_limit
|
||||||
|
if not sets:
|
||||||
|
return {"ok": True}
|
||||||
|
sets.append("updated_at = SYSTIMESTAMP")
|
||||||
|
sql = f"UPDATE daemon_config SET {', '.join(sets)} WHERE id = 1"
|
||||||
|
with conn() as c:
|
||||||
|
c.cursor().execute(sql, params)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run/scan")
|
||||||
|
def run_scan(_admin: dict = Depends(get_admin_user)):
|
||||||
|
"""Manually trigger channel scan (admin only)."""
|
||||||
|
from core.youtube import scan_all_channels
|
||||||
|
new_count = scan_all_channels()
|
||||||
|
with conn() as c:
|
||||||
|
c.cursor().execute("UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1")
|
||||||
|
if new_count > 0:
|
||||||
|
cache.flush()
|
||||||
|
return {"ok": True, "new_videos": new_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run/process")
|
||||||
|
def run_process(limit: int = 10, _admin: dict = Depends(get_admin_user)):
|
||||||
|
"""Manually trigger video processing (admin only)."""
|
||||||
|
from core.pipeline import process_pending
|
||||||
|
rest_count = process_pending(limit=limit)
|
||||||
|
with conn() as c:
|
||||||
|
c.cursor().execute("UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1")
|
||||||
|
if rest_count > 0:
|
||||||
|
cache.flush()
|
||||||
|
return {"ok": True, "restaurants_extracted": rest_count}
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from core import restaurant, vector
|
from core import restaurant, vector, cache
|
||||||
from core.db import conn
|
from core.db import conn
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -17,8 +17,15 @@ def search_restaurants(
|
|||||||
limit: int = Query(20, le=100),
|
limit: int = Query(20, le=100),
|
||||||
):
|
):
|
||||||
"""Search restaurants by keyword, semantic similarity, or hybrid."""
|
"""Search restaurants by keyword, semantic similarity, or hybrid."""
|
||||||
|
key = cache.make_key("search", f"q={q}", f"m={mode}", f"l={limit}")
|
||||||
|
cached = cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
if mode == "semantic":
|
if mode == "semantic":
|
||||||
return _semantic_search(q, limit)
|
result = _semantic_search(q, limit)
|
||||||
|
cache.set(key, result)
|
||||||
|
return result
|
||||||
elif mode == "hybrid":
|
elif mode == "hybrid":
|
||||||
kw = _keyword_search(q, limit)
|
kw = _keyword_search(q, limit)
|
||||||
sem = _semantic_search(q, limit)
|
sem = _semantic_search(q, limit)
|
||||||
@@ -29,21 +36,31 @@ def search_restaurants(
|
|||||||
if r["id"] not in seen:
|
if r["id"] not in seen:
|
||||||
merged.append(r)
|
merged.append(r)
|
||||||
seen.add(r["id"])
|
seen.add(r["id"])
|
||||||
return merged[:limit]
|
result = merged[:limit]
|
||||||
|
cache.set(key, result)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
return _keyword_search(q, limit)
|
result = _keyword_search(q, limit)
|
||||||
|
cache.set(key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _keyword_search(q: str, limit: int) -> list[dict]:
|
def _keyword_search(q: str, limit: int) -> list[dict]:
|
||||||
|
# JOIN video_restaurants to also search foods_mentioned and video title
|
||||||
sql = """
|
sql = """
|
||||||
SELECT id, name, address, region, latitude, longitude,
|
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||||
cuisine_type, price_range
|
r.cuisine_type, r.price_range, r.google_place_id,
|
||||||
FROM restaurants
|
r.business_status, r.rating, r.rating_count
|
||||||
WHERE latitude IS NOT NULL
|
FROM restaurants r
|
||||||
AND (UPPER(name) LIKE UPPER(:q)
|
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||||
OR UPPER(address) LIKE UPPER(:q)
|
JOIN videos v ON v.id = vr.video_id
|
||||||
OR UPPER(region) LIKE UPPER(:q)
|
WHERE r.latitude IS NOT NULL
|
||||||
OR UPPER(cuisine_type) LIKE UPPER(:q))
|
AND (UPPER(r.name) LIKE UPPER(:q)
|
||||||
|
OR UPPER(r.address) LIKE UPPER(:q)
|
||||||
|
OR UPPER(r.region) LIKE UPPER(:q)
|
||||||
|
OR UPPER(r.cuisine_type) LIKE UPPER(:q)
|
||||||
|
OR UPPER(vr.foods_mentioned) LIKE UPPER(:q)
|
||||||
|
OR UPPER(v.title) LIKE UPPER(:q))
|
||||||
FETCH FIRST :lim ROWS ONLY
|
FETCH FIRST :lim ROWS ONLY
|
||||||
"""
|
"""
|
||||||
pattern = f"%{q}%"
|
pattern = f"%{q}%"
|
||||||
@@ -51,18 +68,56 @@ def _keyword_search(q: str, limit: int) -> list[dict]:
|
|||||||
cur = c.cursor()
|
cur = c.cursor()
|
||||||
cur.execute(sql, {"q": pattern, "lim": limit})
|
cur.execute(sql, {"q": pattern, "lim": limit})
|
||||||
cols = [d[0].lower() for d in cur.description]
|
cols = [d[0].lower() for d in cur.description]
|
||||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
# Attach channel names
|
||||||
|
if rows:
|
||||||
|
_attach_channels(rows)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _semantic_search(q: str, limit: int) -> list[dict]:
|
def _semantic_search(q: str, limit: int) -> list[dict]:
|
||||||
similar = vector.search_similar(q, top_k=limit)
|
similar = vector.search_similar(q, top_k=max(30, limit * 3))
|
||||||
if not similar:
|
if not similar:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
rest_ids = list({s["restaurant_id"] for s in similar})
|
# Deduplicate by restaurant_id, preserving distance order (best first)
|
||||||
|
seen: set[str] = set()
|
||||||
|
ordered_ids: list[str] = []
|
||||||
|
for s in similar:
|
||||||
|
rid = s["restaurant_id"]
|
||||||
|
if rid not in seen:
|
||||||
|
seen.add(rid)
|
||||||
|
ordered_ids.append(rid)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for rid in rest_ids[:limit]:
|
for rid in ordered_ids[:limit]:
|
||||||
r = restaurant.get_by_id(rid)
|
r = restaurant.get_by_id(rid)
|
||||||
if r and r.get("latitude"):
|
if r and r.get("latitude"):
|
||||||
results.append(r)
|
results.append(r)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
_attach_channels(results)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_channels(rows: list[dict]):
|
||||||
|
"""Attach channel names to each restaurant dict."""
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
placeholders = ", ".join(f":id{i}" for i in range(len(ids)))
|
||||||
|
sql = f"""
|
||||||
|
SELECT DISTINCT vr.restaurant_id, c.channel_name
|
||||||
|
FROM video_restaurants vr
|
||||||
|
JOIN videos v ON v.id = vr.video_id
|
||||||
|
JOIN channels c ON c.id = v.channel_id
|
||||||
|
WHERE vr.restaurant_id IN ({placeholders})
|
||||||
|
"""
|
||||||
|
params = {f"id{i}": rid for i, rid in enumerate(ids)}
|
||||||
|
ch_map: dict[str, list[str]] = {}
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(sql, params)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
ch_map.setdefault(row[0], []).append(row[1])
|
||||||
|
for r in rows:
|
||||||
|
r["channels"] = ch_map.get(r["id"], [])
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import random
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from api.deps import get_admin_user
|
||||||
|
|
||||||
from core.db import conn
|
from core.db import conn
|
||||||
from core.pipeline import process_pending
|
from core.pipeline import process_pending
|
||||||
|
from core import cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -23,11 +26,9 @@ _executor = ThreadPoolExecutor(max_workers=4)
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
def list_videos(
|
def list_videos(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = Query(50, le=500),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
):
|
):
|
||||||
conditions = []
|
conditions = []
|
||||||
params: dict = {"lim": limit, "off": offset}
|
params: dict = {}
|
||||||
if status:
|
if status:
|
||||||
conditions.append("v.status = :st")
|
conditions.append("v.status = :st")
|
||||||
params["st"] = status
|
params["st"] = status
|
||||||
@@ -44,7 +45,6 @@ def list_videos(
|
|||||||
JOIN channels c ON c.id = v.channel_id
|
JOIN channels c ON c.id = v.channel_id
|
||||||
{where}
|
{where}
|
||||||
ORDER BY v.published_at DESC NULLS LAST
|
ORDER BY v.published_at DESC NULLS LAST
|
||||||
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
|
||||||
"""
|
"""
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
cur = c.cursor()
|
cur = c.cursor()
|
||||||
@@ -100,7 +100,7 @@ def bulk_extract_pending_count():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-extract")
|
@router.post("/bulk-extract")
|
||||||
def bulk_extract():
|
def bulk_extract(_admin: dict = Depends(get_admin_user)):
|
||||||
"""Process all unextracted videos with random delays. Streams SSE progress."""
|
"""Process all unextracted videos with random delays. Streams SSE progress."""
|
||||||
from core.pipeline import process_video_extract
|
from core.pipeline import process_video_extract
|
||||||
|
|
||||||
@@ -131,6 +131,8 @@ def bulk_extract():
|
|||||||
logger.error("Bulk extract error for %s: %s", v["video_id"], e)
|
logger.error("Bulk extract error for %s: %s", v["video_id"], e)
|
||||||
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
|
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
if total_restaurants > 0:
|
||||||
|
cache.flush()
|
||||||
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'total_restaurants': total_restaurants})}\n\n"
|
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'total_restaurants': total_restaurants})}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
@@ -159,7 +161,7 @@ def bulk_transcript_pending_count():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-transcript")
|
@router.post("/bulk-transcript")
|
||||||
def bulk_transcript():
|
def bulk_transcript(_admin: dict = Depends(get_admin_user)):
|
||||||
"""Fetch transcripts for all videos missing them. Streams SSE progress."""
|
"""Fetch transcripts for all videos missing them. Streams SSE progress."""
|
||||||
from core.youtube import get_transcript
|
from core.youtube import get_transcript
|
||||||
|
|
||||||
@@ -196,11 +198,133 @@ def bulk_transcript():
|
|||||||
logger.error("Bulk transcript error for %s: %s", v["video_id"], e)
|
logger.error("Bulk transcript error for %s: %s", v["video_id"], e)
|
||||||
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
|
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
if success > 0:
|
||||||
|
cache.flush()
|
||||||
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'success': success})}\n\n"
|
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'success': success})}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/remap-cuisine")
|
||||||
|
def remap_cuisine(_admin: dict = Depends(get_admin_user)):
|
||||||
|
"""Remap all restaurant cuisine_type using LLM. Streams SSE progress."""
|
||||||
|
from core.cuisine import build_remap_prompt, CUISINE_TYPES, VALID_PREFIXES
|
||||||
|
from core.extractor import _llm, _parse_json
|
||||||
|
from core.db import conn as db_conn
|
||||||
|
|
||||||
|
BATCH = 20 # restaurants per LLM call (smaller for better accuracy)
|
||||||
|
|
||||||
|
def _apply_batch(batch: list[dict], valid_set: set[str]) -> tuple[int, list[dict]]:
|
||||||
|
"""Run LLM on a batch. Returns (updated_count, missed_items)."""
|
||||||
|
prompt = build_remap_prompt(batch)
|
||||||
|
raw = _llm(prompt, max_tokens=4096)
|
||||||
|
result = _parse_json(raw)
|
||||||
|
if not isinstance(result, list):
|
||||||
|
result = []
|
||||||
|
|
||||||
|
result_map = {}
|
||||||
|
for item in result:
|
||||||
|
rid = item.get("id")
|
||||||
|
new_type = item.get("cuisine_type")
|
||||||
|
if rid and new_type:
|
||||||
|
result_map[rid] = new_type
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
missed = []
|
||||||
|
for r in batch:
|
||||||
|
rid = r["id"]
|
||||||
|
new_type = result_map.get(rid)
|
||||||
|
if not new_type:
|
||||||
|
missed.append(r)
|
||||||
|
continue
|
||||||
|
# Accept if exact match or valid prefix
|
||||||
|
if new_type not in valid_set and not new_type.startswith(VALID_PREFIXES):
|
||||||
|
missed.append(r)
|
||||||
|
continue
|
||||||
|
with db_conn() as c:
|
||||||
|
c.cursor().execute(
|
||||||
|
"UPDATE restaurants SET cuisine_type = :ct WHERE id = :id",
|
||||||
|
{"ct": new_type, "id": rid},
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
return updated, missed
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
sql = """
|
||||||
|
SELECT r.id, r.name, r.cuisine_type,
|
||||||
|
(SELECT LISTAGG(vr.foods_mentioned, '|') WITHIN GROUP (ORDER BY vr.id)
|
||||||
|
FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id)
|
||||||
|
ORDER BY r.name
|
||||||
|
"""
|
||||||
|
with db_conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(sql)
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
foods_raw = row[3].read() if hasattr(row[3], "read") else row[3]
|
||||||
|
rows.append({"id": row[0], "name": row[1], "cuisine_type": row[2], "foods_mentioned": foods_raw})
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
|
||||||
|
|
||||||
|
valid_set = set(CUISINE_TYPES)
|
||||||
|
updated = 0
|
||||||
|
all_missed: list[dict] = []
|
||||||
|
|
||||||
|
# Pass 1: process all in batches
|
||||||
|
for i in range(0, total, BATCH):
|
||||||
|
batch = rows[i : i + BATCH]
|
||||||
|
yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total, 'pass': 1})}\n\n"
|
||||||
|
try:
|
||||||
|
cnt, missed = _apply_batch(batch, valid_set)
|
||||||
|
updated += cnt
|
||||||
|
all_missed.extend(missed)
|
||||||
|
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated, 'missed': len(all_missed)})}\n\n"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Remap batch error at %d: %s", i, e, exc_info=True)
|
||||||
|
all_missed.extend(batch)
|
||||||
|
yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n"
|
||||||
|
|
||||||
|
# Pass 2: retry missed items (smaller batches for accuracy)
|
||||||
|
if all_missed:
|
||||||
|
yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n"
|
||||||
|
RETRY_BATCH = 10
|
||||||
|
for i in range(0, len(all_missed), RETRY_BATCH):
|
||||||
|
batch = all_missed[i : i + RETRY_BATCH]
|
||||||
|
try:
|
||||||
|
cnt, _ = _apply_batch(batch, valid_set)
|
||||||
|
updated += cnt
|
||||||
|
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + RETRY_BATCH, len(all_missed)), 'total': len(all_missed), 'updated': updated, 'pass': 2})}\n\n"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Remap retry error at %d: %s", i, e, exc_info=True)
|
||||||
|
|
||||||
|
cache.flush()
|
||||||
|
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rebuild-vectors")
|
||||||
|
def rebuild_vectors(_admin: dict = Depends(get_admin_user)):
|
||||||
|
"""Rebuild all restaurant vector embeddings. Streams SSE progress."""
|
||||||
|
from core import vector
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
yield f"data: {_json.dumps({'type': 'start'})}\n\n"
|
||||||
|
try:
|
||||||
|
for progress in vector.rebuild_all_vectors():
|
||||||
|
yield f"data: {_json.dumps({'type': progress.get('status', 'progress'), **progress})}\n\n"
|
||||||
|
cache.flush()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Rebuild vectors error: %s", e, exc_info=True)
|
||||||
|
yield f"data: {_json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/extract/prompt")
|
@router.get("/extract/prompt")
|
||||||
def get_extract_prompt():
|
def get_extract_prompt():
|
||||||
"""Get the current LLM extraction prompt template."""
|
"""Get the current LLM extraction prompt template."""
|
||||||
@@ -209,11 +333,14 @@ def get_extract_prompt():
|
|||||||
|
|
||||||
|
|
||||||
def _do_process(limit: int):
|
def _do_process(limit: int):
|
||||||
return {"restaurants_extracted": process_pending(limit)}
|
result = process_pending(limit)
|
||||||
|
if result > 0:
|
||||||
|
cache.flush()
|
||||||
|
return {"restaurants_extracted": result}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/process")
|
@router.post("/process")
|
||||||
async def trigger_processing(limit: int = Query(5, le=20)):
|
async def trigger_processing(limit: int = Query(5, le=20), _admin: dict = Depends(get_admin_user)):
|
||||||
"""Manually trigger processing of pending videos (non-blocking)."""
|
"""Manually trigger processing of pending videos (non-blocking)."""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
return await loop.run_in_executor(_executor, _do_process, limit)
|
return await loop.run_in_executor(_executor, _do_process, limit)
|
||||||
@@ -318,11 +445,12 @@ def _do_fetch_transcript(video_db_id: str, mode: str):
|
|||||||
{"txt": transcript, "vid": video_db_id},
|
{"txt": transcript, "vid": video_db_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cache.flush()
|
||||||
return {"ok": True, "length": len(transcript), "source": source}
|
return {"ok": True, "length": len(transcript), "source": source}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_db_id}/fetch-transcript")
|
@router.post("/{video_db_id}/fetch-transcript")
|
||||||
async def fetch_transcript(video_db_id: str, mode: str = Query("auto")):
|
async def fetch_transcript(video_db_id: str, mode: str = Query("auto"), _admin: dict = Depends(get_admin_user)):
|
||||||
"""Fetch and save transcript for a video (non-blocking)."""
|
"""Fetch and save transcript for a video (non-blocking)."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -359,11 +487,12 @@ def _do_extract(video_db_id: str, custom_prompt: str | None):
|
|||||||
transcript,
|
transcript,
|
||||||
custom_prompt=custom_prompt,
|
custom_prompt=custom_prompt,
|
||||||
)
|
)
|
||||||
|
cache.flush()
|
||||||
return {"ok": True, "restaurants_extracted": count}
|
return {"ok": True, "restaurants_extracted": count}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_db_id}/extract")
|
@router.post("/{video_db_id}/extract")
|
||||||
async def extract_restaurants_from_video(video_db_id: str, body: dict = None):
|
async def extract_restaurants_from_video(video_db_id: str, body: dict = None, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Run LLM extraction on an existing transcript (non-blocking)."""
|
"""Run LLM extraction on an existing transcript (non-blocking)."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
custom_prompt = body.get("prompt") if body else None
|
custom_prompt = body.get("prompt") if body else None
|
||||||
@@ -375,7 +504,7 @@ async def extract_restaurants_from_video(video_db_id: str, body: dict = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{video_db_id}/skip")
|
@router.post("/{video_db_id}/skip")
|
||||||
def skip_video(video_db_id: str):
|
def skip_video(video_db_id: str, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Mark a video as skipped."""
|
"""Mark a video as skipped."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
@@ -386,11 +515,12 @@ def skip_video(video_db_id: str):
|
|||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise HTTPException(404, "Video not found")
|
raise HTTPException(404, "Video not found")
|
||||||
|
cache.flush()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{video_db_id}")
|
@router.delete("/{video_db_id}")
|
||||||
def delete_video(video_db_id: str):
|
def delete_video(video_db_id: str, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Delete a video and its related data."""
|
"""Delete a video and its related data."""
|
||||||
from core.db import conn as get_conn
|
from core.db import conn as get_conn
|
||||||
with get_conn() as c:
|
with get_conn() as c:
|
||||||
@@ -441,11 +571,12 @@ def delete_video(video_db_id: str):
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
raise HTTPException(404, "Video not found")
|
raise HTTPException(404, "Video not found")
|
||||||
|
cache.flush()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{video_db_id}")
|
@router.put("/{video_db_id}")
|
||||||
def update_video(video_db_id: str, body: dict):
|
def update_video(video_db_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Update video title."""
|
"""Update video title."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
title = body.get("title")
|
title = body.get("title")
|
||||||
@@ -459,11 +590,12 @@ def update_video(video_db_id: str, body: dict):
|
|||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise HTTPException(404, "Video not found")
|
raise HTTPException(404, "Video not found")
|
||||||
|
cache.flush()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{video_db_id}/restaurants/{restaurant_id}")
|
@router.delete("/{video_db_id}/restaurants/{restaurant_id}")
|
||||||
def delete_video_restaurant(video_db_id: str, restaurant_id: str):
|
def delete_video_restaurant(video_db_id: str, restaurant_id: str, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Delete a video-restaurant mapping. Also cleans up orphaned restaurant."""
|
"""Delete a video-restaurant mapping. Also cleans up orphaned restaurant."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
@@ -487,11 +619,12 @@ def delete_video_restaurant(video_db_id: str, restaurant_id: str):
|
|||||||
DELETE FROM restaurants WHERE id = :rid
|
DELETE FROM restaurants WHERE id = :rid
|
||||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
|
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
|
||||||
""", {"rid": restaurant_id})
|
""", {"rid": restaurant_id})
|
||||||
|
cache.flush()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_db_id}/restaurants/manual")
|
@router.post("/{video_db_id}/restaurants/manual")
|
||||||
def add_manual_restaurant(video_db_id: str, body: dict):
|
def add_manual_restaurant(video_db_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Manually add a restaurant and link it to a video."""
|
"""Manually add a restaurant and link it to a video."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from core import restaurant as rest_mod
|
from core import restaurant as rest_mod
|
||||||
@@ -538,11 +671,12 @@ def add_manual_restaurant(video_db_id: str, body: dict):
|
|||||||
guests=guests if isinstance(guests, list) else [],
|
guests=guests if isinstance(guests, list) else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cache.flush()
|
||||||
return {"ok": True, "restaurant_id": rid, "link_id": link_id}
|
return {"ok": True, "restaurant_id": rid, "link_id": link_id}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{video_db_id}/restaurants/{restaurant_id}")
|
@router.put("/{video_db_id}/restaurants/{restaurant_id}")
|
||||||
def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
|
def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
|
||||||
"""Update restaurant info linked to a video.
|
"""Update restaurant info linked to a video.
|
||||||
|
|
||||||
If name changed, re-geocode and remap to a new restaurant record.
|
If name changed, re-geocode and remap to a new restaurant record.
|
||||||
@@ -552,6 +686,9 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
|
|||||||
|
|
||||||
# Check if name changed — need to remap
|
# Check if name changed — need to remap
|
||||||
new_name = body.get("name", "").strip() if "name" in body else None
|
new_name = body.get("name", "").strip() if "name" in body else None
|
||||||
|
name_changed = False
|
||||||
|
active_rid = restaurant_id
|
||||||
|
|
||||||
if new_name:
|
if new_name:
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
cur = c.cursor()
|
cur = c.cursor()
|
||||||
@@ -560,101 +697,126 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
|
|||||||
old_name = row[0] if row else ""
|
old_name = row[0] if row else ""
|
||||||
|
|
||||||
if old_name != new_name:
|
if old_name != new_name:
|
||||||
# Name changed: geocode new restaurant, remap
|
name_changed = True
|
||||||
from core import restaurant as rest_mod
|
from core import restaurant as rest_mod
|
||||||
from core.geocoding import geocode_restaurant
|
from core.geocoding import geocode_restaurant
|
||||||
|
|
||||||
address = body.get("address", "").strip() or body.get("region", "").strip() or ""
|
address = (body.get("address") or "").strip() or (body.get("region") or "").strip() or ""
|
||||||
geo = geocode_restaurant(new_name, address)
|
geo = geocode_restaurant(new_name, address)
|
||||||
if not geo:
|
if not geo:
|
||||||
raise HTTPException(400, f"'{new_name}' 위치를 찾을 수 없습니다.")
|
# Geocode failed — just rename in place without remapping
|
||||||
|
with conn() as c:
|
||||||
new_rid = rest_mod.upsert(
|
cur = c.cursor()
|
||||||
name=new_name,
|
cur.execute("UPDATE restaurants SET name = :name, updated_at = SYSTIMESTAMP WHERE id = :rid",
|
||||||
address=geo.get("formatted_address") or body.get("address"),
|
{"name": new_name, "rid": restaurant_id})
|
||||||
region=body.get("region"),
|
else:
|
||||||
latitude=geo["latitude"],
|
new_rid = rest_mod.upsert(
|
||||||
longitude=geo["longitude"],
|
name=new_name,
|
||||||
cuisine_type=body.get("cuisine_type"),
|
address=geo.get("formatted_address") or body.get("address"),
|
||||||
price_range=body.get("price_range"),
|
region=body.get("region"),
|
||||||
google_place_id=geo.get("google_place_id"),
|
latitude=geo["latitude"],
|
||||||
phone=geo.get("phone"),
|
longitude=geo["longitude"],
|
||||||
website=geo.get("website"),
|
cuisine_type=body.get("cuisine_type"),
|
||||||
business_status=geo.get("business_status"),
|
price_range=body.get("price_range"),
|
||||||
rating=geo.get("rating"),
|
google_place_id=geo.get("google_place_id"),
|
||||||
rating_count=geo.get("rating_count"),
|
phone=geo.get("phone"),
|
||||||
)
|
website=geo.get("website"),
|
||||||
|
business_status=geo.get("business_status"),
|
||||||
# Read existing mapping data, delete old, create new
|
rating=geo.get("rating"),
|
||||||
with conn() as c:
|
rating_count=geo.get("rating_count"),
|
||||||
cur = c.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT foods_mentioned, evaluation, guests FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
|
|
||||||
{"vid": video_db_id, "rid": restaurant_id},
|
|
||||||
)
|
|
||||||
old_vr = cur.fetchone()
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
|
|
||||||
{"vid": video_db_id, "rid": restaurant_id},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build new mapping values from body or old data
|
# Read existing mapping data, delete old, create new
|
||||||
def _parse(val, default):
|
with conn() as c:
|
||||||
if val is None:
|
cur = c.cursor()
|
||||||
return default
|
cur.execute(
|
||||||
if hasattr(val, "read"):
|
"SELECT foods_mentioned, evaluation, guests FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
|
||||||
val = val.read()
|
{"vid": video_db_id, "rid": restaurant_id},
|
||||||
if isinstance(val, (list, dict)):
|
)
|
||||||
return val
|
old_vr = cur.fetchone()
|
||||||
try:
|
|
||||||
return _json.loads(val)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
old_foods = _parse(old_vr[0], []) if old_vr else []
|
cur.execute(
|
||||||
old_eval = _parse(old_vr[1], {}) if old_vr else {}
|
"DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
|
||||||
old_guests = _parse(old_vr[2], []) if old_vr else []
|
{"vid": video_db_id, "rid": restaurant_id},
|
||||||
|
)
|
||||||
|
|
||||||
foods = body.get("foods_mentioned", old_foods)
|
def _parse(val, default):
|
||||||
evaluation = body.get("evaluation", old_eval)
|
if val is None:
|
||||||
guests = body.get("guests", old_guests)
|
return default
|
||||||
|
if hasattr(val, "read"):
|
||||||
|
val = val.read()
|
||||||
|
if isinstance(val, (list, dict)):
|
||||||
|
return val
|
||||||
|
try:
|
||||||
|
return _json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
eval_text = evaluation.get("text", "") if isinstance(evaluation, dict) else str(evaluation or "")
|
old_foods = _parse(old_vr[0], []) if old_vr else []
|
||||||
|
old_eval = _parse(old_vr[1], {}) if old_vr else {}
|
||||||
|
old_guests = _parse(old_vr[2], []) if old_vr else []
|
||||||
|
|
||||||
rest_mod.link_video_restaurant(
|
foods = body.get("foods_mentioned", old_foods)
|
||||||
video_db_id=video_db_id,
|
evaluation = body.get("evaluation", old_eval)
|
||||||
restaurant_id=new_rid,
|
guests = body.get("guests", old_guests)
|
||||||
foods=foods if isinstance(foods, list) else [],
|
|
||||||
evaluation=eval_text or None,
|
|
||||||
guests=guests if isinstance(guests, list) else [],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"ok": True, "remapped": True, "new_restaurant_id": new_rid}
|
eval_text = evaluation.get("text", "") if isinstance(evaluation, dict) else str(evaluation or "")
|
||||||
|
|
||||||
# No name change — update in place
|
rest_mod.link_video_restaurant(
|
||||||
with conn() as c:
|
video_db_id=video_db_id,
|
||||||
cur = c.cursor()
|
restaurant_id=new_rid,
|
||||||
r_sets = []
|
foods=foods if isinstance(foods, list) else [],
|
||||||
r_params: dict = {"rid": restaurant_id}
|
evaluation=eval_text or None,
|
||||||
for field in ("name", "address", "region", "cuisine_type", "price_range"):
|
guests=guests if isinstance(guests, list) else [],
|
||||||
if field in body:
|
)
|
||||||
r_sets.append(f"{field} = :{field}")
|
|
||||||
r_params[field] = body[field]
|
|
||||||
if r_sets:
|
|
||||||
r_sets.append("updated_at = SYSTIMESTAMP")
|
|
||||||
sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid"
|
|
||||||
cur.execute(sql, r_params)
|
|
||||||
|
|
||||||
vr_params: dict = {"vid": video_db_id, "rid": restaurant_id}
|
active_rid = new_rid
|
||||||
vr_sets = []
|
|
||||||
for field in ("foods_mentioned", "evaluation", "guests"):
|
|
||||||
if field in body:
|
|
||||||
vr_sets.append(f"{field} = :{field}")
|
|
||||||
val = body[field]
|
|
||||||
vr_params[field] = _json.dumps(val, ensure_ascii=False) if isinstance(val, (list, dict)) else val
|
|
||||||
if vr_sets:
|
|
||||||
sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid"
|
|
||||||
cur.execute(sql, vr_params)
|
|
||||||
|
|
||||||
return {"ok": True}
|
# 기존 식당이 다른 영상 매핑이 없으면 고아 → 삭제
|
||||||
|
if new_rid != restaurant_id:
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM video_restaurants WHERE restaurant_id = :rid",
|
||||||
|
{"rid": restaurant_id},
|
||||||
|
)
|
||||||
|
remaining = cur.fetchone()[0]
|
||||||
|
if remaining == 0:
|
||||||
|
cur.execute("DELETE FROM restaurant_vectors WHERE restaurant_id = :rid", {"rid": restaurant_id})
|
||||||
|
cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id})
|
||||||
|
cur.execute("DELETE FROM user_favorites WHERE restaurant_id = :rid", {"rid": restaurant_id})
|
||||||
|
cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id})
|
||||||
|
|
||||||
|
# Update remaining fields in place (skip name if already remapped)
|
||||||
|
if not name_changed:
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
r_sets = []
|
||||||
|
r_params: dict = {"rid": active_rid}
|
||||||
|
for field in ("address", "region", "cuisine_type", "price_range"):
|
||||||
|
if field in body:
|
||||||
|
r_sets.append(f"{field} = :{field}")
|
||||||
|
r_params[field] = body[field]
|
||||||
|
if r_sets:
|
||||||
|
r_sets.append("updated_at = SYSTIMESTAMP")
|
||||||
|
sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid"
|
||||||
|
cur.execute(sql, r_params)
|
||||||
|
|
||||||
|
vr_params: dict = {"vid": video_db_id, "rid": active_rid}
|
||||||
|
vr_sets = []
|
||||||
|
for field in ("foods_mentioned", "evaluation", "guests"):
|
||||||
|
if field in body:
|
||||||
|
vr_sets.append(f"{field} = :{field}")
|
||||||
|
val = body[field]
|
||||||
|
vr_params[field] = _json.dumps(val, ensure_ascii=False) if isinstance(val, (list, dict)) else val
|
||||||
|
if vr_sets:
|
||||||
|
sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid"
|
||||||
|
cur.execute(sql, vr_params)
|
||||||
|
|
||||||
|
cache.flush()
|
||||||
|
result: dict = {"ok": True}
|
||||||
|
if name_changed:
|
||||||
|
result["remapped"] = active_rid != restaurant_id
|
||||||
|
if active_rid != restaurant_id:
|
||||||
|
result["new_restaurant_id"] = active_rid
|
||||||
|
return result
|
||||||
|
|||||||
107
backend/core/cache.py
Normal file
107
backend/core/cache.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Redis cache layer — graceful fallback when Redis is unavailable."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client: redis.Redis | None = None
|
||||||
|
_disabled = False
|
||||||
|
|
||||||
|
DEFAULT_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> redis.Redis | None:
|
||||||
|
global _client, _disabled
|
||||||
|
if _disabled:
|
||||||
|
return None
|
||||||
|
if _client is None:
|
||||||
|
host = os.environ.get("REDIS_HOST", "192.168.0.147")
|
||||||
|
port = int(os.environ.get("REDIS_PORT", "6379"))
|
||||||
|
db = int(os.environ.get("REDIS_DB", "0"))
|
||||||
|
try:
|
||||||
|
_client = redis.Redis(
|
||||||
|
host=host, port=port, db=db,
|
||||||
|
socket_connect_timeout=2,
|
||||||
|
socket_timeout=2,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
_client.ping()
|
||||||
|
logger.info("Redis connected: %s:%s/%s", host, port, db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Redis unavailable (%s), caching disabled", e)
|
||||||
|
_client = None
|
||||||
|
_disabled = True
|
||||||
|
return None
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def make_key(*parts: Any) -> str:
|
||||||
|
"""Build a cache key like 'tasteby:restaurants:cuisine=한식:limit=100'."""
|
||||||
|
return "tasteby:" + ":".join(str(p) for p in parts if p is not None and p != "")
|
||||||
|
|
||||||
|
|
||||||
|
def get(key: str) -> Any | None:
|
||||||
|
"""Get cached value. Returns None on miss or error."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
if not client:
|
||||||
|
return None
|
||||||
|
val = client.get(key)
|
||||||
|
if val is not None:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Cache get error: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set(key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
|
||||||
|
"""Cache a value as JSON with TTL."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
client.setex(key, ttl, json.dumps(value, ensure_ascii=False, default=str))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Cache set error: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
"""Flush all tasteby cache keys."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = client.scan(cursor, match="tasteby:*", count=200)
|
||||||
|
if keys:
|
||||||
|
client.delete(*keys)
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
logger.info("Cache flushed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Cache flush error: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_prefix(prefix: str) -> None:
|
||||||
|
"""Delete all keys matching a prefix."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = client.scan(cursor, match=f"{prefix}*", count=200)
|
||||||
|
if keys:
|
||||||
|
client.delete(*keys)
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Cache invalidate error: %s", e)
|
||||||
102
backend/core/cuisine.py
Normal file
102
backend/core/cuisine.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Standardized cuisine type taxonomy and LLM remapping."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ── Canonical cuisine types ──
|
||||||
|
# Format: "대분류|소분류"
|
||||||
|
CUISINE_TYPES = [
|
||||||
|
# 한식
|
||||||
|
"한식|백반/한정식",
|
||||||
|
"한식|국밥/해장국",
|
||||||
|
"한식|찌개/전골/탕",
|
||||||
|
"한식|삼겹살/돼지구이",
|
||||||
|
"한식|소고기/한우구이",
|
||||||
|
"한식|곱창/막창",
|
||||||
|
"한식|닭/오리구이",
|
||||||
|
"한식|족발/보쌈",
|
||||||
|
"한식|회/횟집",
|
||||||
|
"한식|해산물",
|
||||||
|
"한식|분식",
|
||||||
|
"한식|면",
|
||||||
|
"한식|죽/죽집",
|
||||||
|
"한식|순대/순대국",
|
||||||
|
"한식|장어/민물",
|
||||||
|
"한식|주점/포차",
|
||||||
|
# 일식
|
||||||
|
"일식|스시/오마카세",
|
||||||
|
"일식|라멘",
|
||||||
|
"일식|돈카츠",
|
||||||
|
"일식|텐동/튀김",
|
||||||
|
"일식|이자카야",
|
||||||
|
"일식|야키니쿠",
|
||||||
|
"일식|카레",
|
||||||
|
"일식|소바/우동",
|
||||||
|
# 중식
|
||||||
|
"중식|중화요리",
|
||||||
|
"중식|마라/훠궈",
|
||||||
|
"중식|딤섬/만두",
|
||||||
|
"중식|양꼬치",
|
||||||
|
# 양식
|
||||||
|
"양식|파스타/이탈리안",
|
||||||
|
"양식|스테이크",
|
||||||
|
"양식|햄버거",
|
||||||
|
"양식|피자",
|
||||||
|
"양식|프렌치",
|
||||||
|
"양식|바베큐",
|
||||||
|
"양식|브런치",
|
||||||
|
"양식|비건/샐러드",
|
||||||
|
# 아시아
|
||||||
|
"아시아|베트남",
|
||||||
|
"아시아|태국",
|
||||||
|
"아시아|인도/중동",
|
||||||
|
"아시아|동남아기타",
|
||||||
|
# 기타
|
||||||
|
"기타|치킨",
|
||||||
|
"기타|카페/디저트",
|
||||||
|
"기타|베이커리",
|
||||||
|
"기타|뷔페",
|
||||||
|
"기타|퓨전",
|
||||||
|
]
|
||||||
|
|
||||||
|
# For LLM prompt
|
||||||
|
CUISINE_LIST_TEXT = "\n".join(f" - {c}" for c in CUISINE_TYPES)
|
||||||
|
|
||||||
|
_REMAP_PROMPT = """\
|
||||||
|
아래 식당들의 cuisine_type을 표준 분류로 매핑하세요.
|
||||||
|
|
||||||
|
표준 분류 목록 (반드시 이 중 하나를 선택):
|
||||||
|
{cuisine_types}
|
||||||
|
|
||||||
|
식당 목록:
|
||||||
|
{restaurants}
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 모든 식당에 대해 빠짐없이 결과를 반환 (총 {count}개 모두 반환해야 함)
|
||||||
|
- 반드시 위 표준 분류 목록의 값을 그대로 복사하여 사용 (오타 금지)
|
||||||
|
- 식당 이름, 현재 분류, 메뉴를 종합적으로 고려
|
||||||
|
- JSON 배열만 반환, 설명 없음
|
||||||
|
- 형식: [{{"id": "식당ID", "cuisine_type": "한식|국밥/해장국"}}, ...]
|
||||||
|
|
||||||
|
JSON 배열:"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_remap_prompt(restaurants: list[dict]) -> str:
|
||||||
|
"""Build a prompt for remapping cuisine types."""
|
||||||
|
items = []
|
||||||
|
for r in restaurants:
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"current_cuisine_type": r.get("cuisine_type"),
|
||||||
|
"foods_mentioned": r.get("foods_mentioned"),
|
||||||
|
})
|
||||||
|
import json
|
||||||
|
return _REMAP_PROMPT.format(
|
||||||
|
cuisine_types=CUISINE_LIST_TEXT,
|
||||||
|
restaurants=json.dumps(items, ensure_ascii=False),
|
||||||
|
count=len(items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Valid prefixes for loose validation
|
||||||
|
VALID_PREFIXES = ("한식|", "일식|", "중식|", "양식|", "아시아|", "기타|")
|
||||||
@@ -20,6 +20,8 @@ from oci.generative_ai_inference.models import (
|
|||||||
UserMessage,
|
UserMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from core.cuisine import CUISINE_LIST_TEXT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -101,18 +103,22 @@ _EXTRACT_PROMPT = """\
|
|||||||
필드:
|
필드:
|
||||||
- name: 식당 이름 (string, 필수)
|
- name: 식당 이름 (string, 필수)
|
||||||
- address: 주소 또는 위치 힌트 (string | null)
|
- address: 주소 또는 위치 힌트 (string | null)
|
||||||
- region: 지역 (예: 서울 강남, 부산 해운대) (string | null)
|
- region: 지역을 "나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null)
|
||||||
- cuisine_type: 음식 종류 (예: 한식, 일식, 중식, 양식, 카페) (string | null)
|
- 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시"
|
||||||
|
- 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕"
|
||||||
|
- 나라는 한글로, 해외 도시도 한글로 표기
|
||||||
|
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
|
||||||
|
{cuisine_types}
|
||||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||||
- foods_mentioned: 언급된 메뉴들 (string[])
|
- foods_mentioned: 언급된 메뉴들 (string[])
|
||||||
- evaluation: 평가 내용 (string | null)
|
- evaluation: 평가 내용 (string | null)
|
||||||
- guests: 함께한 게스트 (string[])
|
- guests: 함께한 게스트 (string[])
|
||||||
|
|
||||||
영상 제목: {title}
|
영상 제목: {{title}}
|
||||||
자막:
|
자막:
|
||||||
{transcript}
|
{{transcript}}
|
||||||
|
|
||||||
JSON 배열:"""
|
JSON 배열:""".format(cuisine_types=CUISINE_LIST_TEXT)
|
||||||
|
|
||||||
|
|
||||||
def extract_restaurants(title: str, transcript: str, custom_prompt: str | None = None) -> tuple[list[dict], str]:
|
def extract_restaurants(title: str, transcript: str, custom_prompt: str | None = None) -> tuple[list[dict], str]:
|
||||||
|
|||||||
@@ -3,12 +3,86 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
import oracledb
|
import oracledb
|
||||||
|
|
||||||
from core.db import conn
|
from core.db import conn
|
||||||
|
|
||||||
|
|
||||||
|
# ── Region parser: address → "나라|시|구" ──
|
||||||
|
|
||||||
|
_CITY_MAP = {
|
||||||
|
"서울특별시": "서울", "서울": "서울",
|
||||||
|
"부산광역시": "부산", "부산": "부산",
|
||||||
|
"대구광역시": "대구", "대구": "대구",
|
||||||
|
"인천광역시": "인천", "인천": "인천",
|
||||||
|
"광주광역시": "광주", "광주": "광주",
|
||||||
|
"대전광역시": "대전", "대전": "대전",
|
||||||
|
"울산광역시": "울산", "울산": "울산",
|
||||||
|
"세종특별자치시": "세종",
|
||||||
|
"경기도": "경기", "경기": "경기",
|
||||||
|
"강원특별자치도": "강원", "강원도": "강원",
|
||||||
|
"충청북도": "충북", "충청남도": "충남",
|
||||||
|
"전라북도": "전북", "전북특별자치도": "전북",
|
||||||
|
"전라남도": "전남",
|
||||||
|
"경상북도": "경북", "경상남도": "경남",
|
||||||
|
"제주특별자치도": "제주",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_region_from_address(address: str | None) -> str | None:
|
||||||
|
"""Parse address into 'country|city|district' format."""
|
||||||
|
if not address:
|
||||||
|
return None
|
||||||
|
addr = address.strip()
|
||||||
|
|
||||||
|
# Japanese
|
||||||
|
if addr.startswith("일본") or "Japan" in addr:
|
||||||
|
city = None
|
||||||
|
if "Tokyo" in addr: city = "도쿄"
|
||||||
|
elif "Osaka" in addr: city = "오사카"
|
||||||
|
elif "Sapporo" in addr or "Hokkaido" in addr: city = "삿포로"
|
||||||
|
elif "Kyoto" in addr: city = "교토"
|
||||||
|
elif "Fukuoka" in addr: city = "후쿠오카"
|
||||||
|
return f"일본|{city}" if city else "일본"
|
||||||
|
|
||||||
|
# Singapore
|
||||||
|
if "Singapore" in addr or "싱가포르" in addr:
|
||||||
|
return "싱가포르"
|
||||||
|
|
||||||
|
# Korean standard: "대한민국 시/도 구/시 ..."
|
||||||
|
if "대한민국" in addr:
|
||||||
|
m = re.match(r"대한민국\s+(\S+)\s+(\S+)", addr)
|
||||||
|
if m:
|
||||||
|
city = _CITY_MAP.get(m.group(1))
|
||||||
|
if city:
|
||||||
|
gu = m.group(2)
|
||||||
|
if gu.endswith(("구", "군", "시")):
|
||||||
|
return f"한국|{city}|{gu}"
|
||||||
|
# Not a district — just city level
|
||||||
|
return f"한국|{city}"
|
||||||
|
# Reversed: "... 구 시 대한민국" / "... 시 KR"
|
||||||
|
parts = addr.split()
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p in _CITY_MAP:
|
||||||
|
city = _CITY_MAP[p]
|
||||||
|
gu = parts[i - 1] if i > 0 and parts[i - 1].endswith(("구", "군", "시")) else None
|
||||||
|
return f"한국|{city}|{gu}" if gu else f"한국|{city}"
|
||||||
|
return "한국"
|
||||||
|
|
||||||
|
# Korean without prefix
|
||||||
|
parts = addr.split()
|
||||||
|
if parts:
|
||||||
|
city = _CITY_MAP.get(parts[0])
|
||||||
|
if city and len(parts) > 1 and parts[1].endswith(("구", "군", "시")):
|
||||||
|
return f"한국|{city}|{parts[1]}"
|
||||||
|
elif city:
|
||||||
|
return f"한국|{city}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
|
def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
|
||||||
"""Truncate a string to fit within max_bytes when encoded as UTF-8."""
|
"""Truncate a string to fit within max_bytes when encoded as UTF-8."""
|
||||||
if not val:
|
if not val:
|
||||||
@@ -19,6 +93,21 @@ def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
|
|||||||
return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip()
|
return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_place_id(google_place_id: str) -> dict | None:
|
||||||
|
"""Find a restaurant by Google Place ID."""
|
||||||
|
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE google_place_id = :gid"
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(sql, {"gid": google_place_id})
|
||||||
|
r = cur.fetchone()
|
||||||
|
if r:
|
||||||
|
return {
|
||||||
|
"id": r[0], "name": r[1], "address": r[2],
|
||||||
|
"region": r[3], "latitude": r[4], "longitude": r[5],
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_by_name(name: str) -> dict | None:
|
def find_by_name(name: str) -> dict | None:
|
||||||
"""Find a restaurant by exact name match."""
|
"""Find a restaurant by exact name match."""
|
||||||
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE name = :n"
|
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE name = :n"
|
||||||
@@ -50,17 +139,27 @@ def upsert(
|
|||||||
rating_count: int | None = None,
|
rating_count: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Insert or update a restaurant. Returns row id."""
|
"""Insert or update a restaurant. Returns row id."""
|
||||||
|
# Auto-derive region from address if not provided
|
||||||
|
if not region and address:
|
||||||
|
region = parse_region_from_address(address)
|
||||||
|
|
||||||
# Truncate fields to fit DB column byte limits (VARCHAR2 is byte-based)
|
# Truncate fields to fit DB column byte limits (VARCHAR2 is byte-based)
|
||||||
price_range = _truncate_bytes(price_range, 50)
|
price_range = _truncate_bytes(price_range, 50)
|
||||||
cuisine_type = _truncate_bytes(cuisine_type, 100)
|
cuisine_type = _truncate_bytes(cuisine_type, 100)
|
||||||
region = _truncate_bytes(region, 100)
|
region = _truncate_bytes(region, 100)
|
||||||
website = _truncate_bytes(website, 500)
|
website = _truncate_bytes(website, 500)
|
||||||
|
|
||||||
existing = find_by_name(name)
|
# 1) google_place_id로 먼저 찾고, 2) 이름으로 찾기
|
||||||
|
existing = None
|
||||||
|
if google_place_id:
|
||||||
|
existing = find_by_place_id(google_place_id)
|
||||||
|
if not existing:
|
||||||
|
existing = find_by_name(name)
|
||||||
if existing:
|
if existing:
|
||||||
sql = """
|
sql = """
|
||||||
UPDATE restaurants
|
UPDATE restaurants
|
||||||
SET address = COALESCE(:addr, address),
|
SET name = :name,
|
||||||
|
address = COALESCE(:addr, address),
|
||||||
region = COALESCE(:reg, region),
|
region = COALESCE(:reg, region),
|
||||||
latitude = COALESCE(:lat, latitude),
|
latitude = COALESCE(:lat, latitude),
|
||||||
longitude = COALESCE(:lng, longitude),
|
longitude = COALESCE(:lng, longitude),
|
||||||
@@ -77,6 +176,7 @@ def upsert(
|
|||||||
"""
|
"""
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
c.cursor().execute(sql, {
|
c.cursor().execute(sql, {
|
||||||
|
"name": name,
|
||||||
"addr": address, "reg": region,
|
"addr": address, "reg": region,
|
||||||
"lat": latitude, "lng": longitude,
|
"lat": latitude, "lng": longitude,
|
||||||
"cuisine": cuisine_type, "price": price_range,
|
"cuisine": cuisine_type, "price": price_range,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import array
|
import array
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import oci
|
import oci
|
||||||
|
import oracledb
|
||||||
from oci.generative_ai_inference import GenerativeAiInferenceClient
|
from oci.generative_ai_inference import GenerativeAiInferenceClient
|
||||||
from oci.generative_ai_inference.models import (
|
from oci.generative_ai_inference.models import (
|
||||||
EmbedTextDetails,
|
EmbedTextDetails,
|
||||||
@@ -14,6 +17,10 @@ from oci.generative_ai_inference.models import (
|
|||||||
|
|
||||||
from core.db import conn
|
from core.db import conn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_EMBED_BATCH_SIZE = 96 # Cohere embed v4 max batch size
|
||||||
|
|
||||||
|
|
||||||
def _embed_texts(texts: list[str]) -> list[list[float]]:
|
def _embed_texts(texts: list[str]) -> list[list[float]]:
|
||||||
config = oci.config.from_file()
|
config = oci.config.from_file()
|
||||||
@@ -34,10 +41,148 @@ def _embed_texts(texts: list[str]) -> list[list[float]]:
|
|||||||
return response.data.embeddings
|
return response.data.embeddings
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_texts_batched(texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Embed texts in batches to respect API limits."""
|
||||||
|
all_embeddings: list[list[float]] = []
|
||||||
|
for i in range(0, len(texts), _EMBED_BATCH_SIZE):
|
||||||
|
batch = texts[i : i + _EMBED_BATCH_SIZE]
|
||||||
|
all_embeddings.extend(_embed_texts(batch))
|
||||||
|
return all_embeddings
|
||||||
|
|
||||||
|
|
||||||
def _to_vec(embedding: list[float]) -> array.array:
|
def _to_vec(embedding: list[float]) -> array.array:
|
||||||
return array.array("f", embedding)
|
return array.array("f", embedding)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_field(val, default):
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
if isinstance(val, (list, dict)):
|
||||||
|
return val
|
||||||
|
if hasattr(val, "read"):
|
||||||
|
val = val.read()
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rich_chunk(rest: dict, video_links: list[dict]) -> str:
|
||||||
|
"""Build a single JSON chunk per restaurant with all relevant info."""
|
||||||
|
# Collect all foods, evaluations, video titles from linked videos
|
||||||
|
all_foods: list[str] = []
|
||||||
|
all_evaluations: list[str] = []
|
||||||
|
video_titles: list[str] = []
|
||||||
|
channel_names: set[str] = set()
|
||||||
|
|
||||||
|
for vl in video_links:
|
||||||
|
if vl.get("title"):
|
||||||
|
video_titles.append(vl["title"])
|
||||||
|
if vl.get("channel_name"):
|
||||||
|
channel_names.add(vl["channel_name"])
|
||||||
|
foods = _parse_json_field(vl.get("foods_mentioned"), [])
|
||||||
|
if foods:
|
||||||
|
all_foods.extend(foods)
|
||||||
|
ev = _parse_json_field(vl.get("evaluation"), {})
|
||||||
|
if isinstance(ev, dict) and ev.get("text"):
|
||||||
|
all_evaluations.append(ev["text"])
|
||||||
|
elif isinstance(ev, str) and ev:
|
||||||
|
all_evaluations.append(ev)
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"name": rest.get("name"),
|
||||||
|
"cuisine_type": rest.get("cuisine_type"),
|
||||||
|
"region": rest.get("region"),
|
||||||
|
"address": rest.get("address"),
|
||||||
|
"price_range": rest.get("price_range"),
|
||||||
|
"menu": list(dict.fromkeys(all_foods)), # deduplicate, preserve order
|
||||||
|
"summary": all_evaluations,
|
||||||
|
"video_titles": video_titles,
|
||||||
|
"channels": sorted(channel_names),
|
||||||
|
}
|
||||||
|
# Remove None/empty values
|
||||||
|
doc = {k: v for k, v in doc.items() if v}
|
||||||
|
return json.dumps(doc, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_all_vectors():
|
||||||
|
"""Rebuild vector embeddings for ALL restaurants.
|
||||||
|
|
||||||
|
Yields progress dicts: {"status": "progress", "current": N, "total": M, "name": "..."}
|
||||||
|
Final yield: {"status": "done", "total": N}
|
||||||
|
"""
|
||||||
|
# 1. Get all restaurants with video links
|
||||||
|
sql_restaurants = """
|
||||||
|
SELECT DISTINCT r.id, r.name, r.address, r.region, r.cuisine_type, r.price_range
|
||||||
|
FROM restaurants r
|
||||||
|
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||||
|
WHERE r.latitude IS NOT NULL
|
||||||
|
ORDER BY r.name
|
||||||
|
"""
|
||||||
|
sql_video_links = """
|
||||||
|
SELECT v.title, vr.foods_mentioned, vr.evaluation, c.channel_name
|
||||||
|
FROM video_restaurants vr
|
||||||
|
JOIN videos v ON v.id = vr.video_id
|
||||||
|
JOIN channels c ON c.id = v.channel_id
|
||||||
|
WHERE vr.restaurant_id = :rid
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Load all restaurant data
|
||||||
|
restaurants_data: list[tuple[dict, str]] = [] # (rest_dict, chunk_text)
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(sql_restaurants)
|
||||||
|
cols = [d[0].lower() for d in cur.description]
|
||||||
|
all_rests = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
total = len(all_rests)
|
||||||
|
logger.info("Rebuilding vectors for %d restaurants", total)
|
||||||
|
|
||||||
|
for i, rest in enumerate(all_rests):
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(sql_video_links, {"rid": rest["id"]})
|
||||||
|
vl_cols = [d[0].lower() for d in cur.description]
|
||||||
|
video_links = [dict(zip(vl_cols, row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
chunk = _build_rich_chunk(rest, video_links)
|
||||||
|
restaurants_data.append((rest, chunk))
|
||||||
|
yield {"status": "progress", "current": i + 1, "total": total, "phase": "prepare", "name": rest["name"]}
|
||||||
|
|
||||||
|
# 2. Delete all existing vectors
|
||||||
|
with conn() as c:
|
||||||
|
c.cursor().execute("DELETE FROM restaurant_vectors")
|
||||||
|
logger.info("Cleared existing vectors")
|
||||||
|
yield {"status": "progress", "current": 0, "total": total, "phase": "embed"}
|
||||||
|
|
||||||
|
# 3. Embed in batches and insert
|
||||||
|
chunks = [chunk for _, chunk in restaurants_data]
|
||||||
|
rest_ids = [rest["id"] for rest, _ in restaurants_data]
|
||||||
|
|
||||||
|
embeddings = _embed_texts_batched(chunks)
|
||||||
|
logger.info("Generated %d embeddings", len(embeddings))
|
||||||
|
|
||||||
|
insert_sql = """
|
||||||
|
INSERT INTO restaurant_vectors (restaurant_id, chunk_text, embedding)
|
||||||
|
VALUES (:rid, :chunk, :emb)
|
||||||
|
"""
|
||||||
|
with conn() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
for i, (rid, chunk, emb) in enumerate(zip(rest_ids, chunks, embeddings)):
|
||||||
|
cur.execute(insert_sql, {
|
||||||
|
"rid": rid,
|
||||||
|
"chunk": chunk,
|
||||||
|
"emb": _to_vec(emb),
|
||||||
|
})
|
||||||
|
if (i + 1) % 50 == 0 or i + 1 == total:
|
||||||
|
yield {"status": "progress", "current": i + 1, "total": total, "phase": "insert"}
|
||||||
|
|
||||||
|
logger.info("Rebuilt vectors for %d restaurants", total)
|
||||||
|
yield {"status": "done", "total": total}
|
||||||
|
|
||||||
|
|
||||||
def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
|
def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
|
||||||
"""Embed and store text chunks for a restaurant.
|
"""Embed and store text chunks for a restaurant.
|
||||||
|
|
||||||
@@ -54,7 +199,6 @@ def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
|
|||||||
VALUES (:rid, :chunk, :emb)
|
VALUES (:rid, :chunk, :emb)
|
||||||
RETURNING id INTO :out_id
|
RETURNING id INTO :out_id
|
||||||
"""
|
"""
|
||||||
import oracledb
|
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
cur = c.cursor()
|
cur = c.cursor()
|
||||||
for chunk, emb in zip(chunks, embeddings):
|
for chunk, emb in zip(chunks, embeddings):
|
||||||
@@ -69,10 +213,11 @@ def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
|
|||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
def search_similar(query: str, top_k: int = 10) -> list[dict]:
|
def search_similar(query: str, top_k: int = 10, max_distance: float = 0.57) -> list[dict]:
|
||||||
"""Semantic search: find restaurants similar to query text.
|
"""Semantic search: find restaurants similar to query text.
|
||||||
|
|
||||||
Returns list of dicts: restaurant_id, chunk_text, distance.
|
Returns list of dicts: restaurant_id, chunk_text, distance.
|
||||||
|
Only results with cosine distance <= max_distance are returned.
|
||||||
"""
|
"""
|
||||||
embeddings = _embed_texts([query])
|
embeddings = _embed_texts([query])
|
||||||
query_vec = _to_vec(embeddings[0])
|
query_vec = _to_vec(embeddings[0])
|
||||||
@@ -81,12 +226,13 @@ def search_similar(query: str, top_k: int = 10) -> list[dict]:
|
|||||||
SELECT rv.restaurant_id, rv.chunk_text,
|
SELECT rv.restaurant_id, rv.chunk_text,
|
||||||
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
|
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
|
||||||
FROM restaurant_vectors rv
|
FROM restaurant_vectors rv
|
||||||
|
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
|
||||||
ORDER BY dist
|
ORDER BY dist
|
||||||
FETCH FIRST :k ROWS ONLY
|
FETCH FIRST :k ROWS ONLY
|
||||||
"""
|
"""
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
cur = c.cursor()
|
cur = c.cursor()
|
||||||
cur.execute(sql, {"qvec": query_vec, "k": top_k})
|
cur.execute(sql, {"qvec": query_vec, "qvec2": query_vec, "k": top_k, "max_dist": max_distance})
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"restaurant_id": r[0],
|
"restaurant_id": r[0],
|
||||||
|
|||||||
@@ -2,24 +2,48 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Channel, Video, VideoDetail, VideoLink, Restaurant } from "@/lib/api";
|
import type { Channel, Video, VideoDetail, VideoLink, Restaurant, DaemonConfig } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
type Tab = "channels" | "videos" | "restaurants" | "users";
|
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [tab, setTab] = useState<Tab>("channels");
|
const [tab, setTab] = useState<Tab>("channels");
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
const isAdmin = user?.is_admin === true;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="min-h-screen bg-gray-50 flex items-center justify-center text-gray-500">로딩 중...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-600 mb-4">로그인이 필요합니다</p>
|
||||||
|
<a href="/" className="text-blue-600 hover:underline">메인으로 돌아가기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<header className="bg-white border-b px-6 py-4">
|
<header className="bg-white border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold">Tasteby Admin</h1>
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-xl font-bold">Tasteby Admin</h1>
|
||||||
|
{!isAdmin && (
|
||||||
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
<a href="/" className="text-sm text-blue-600 hover:underline">
|
||||||
← 메인으로
|
← 메인으로
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-3 flex gap-1">
|
<nav className="mt-3 flex gap-1">
|
||||||
{(["channels", "videos", "restaurants", "users"] as Tab[]).map((t) => (
|
{(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
@@ -29,24 +53,25 @@ export default function AdminPage() {
|
|||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : "유저 관리"}
|
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-6xl mx-auto p-6">
|
<main className="max-w-6xl mx-auto p-6">
|
||||||
{tab === "channels" && <ChannelsPanel />}
|
{tab === "channels" && <ChannelsPanel isAdmin={isAdmin} />}
|
||||||
{tab === "videos" && <VideosPanel />}
|
{tab === "videos" && <VideosPanel isAdmin={isAdmin} />}
|
||||||
{tab === "restaurants" && <RestaurantsPanel />}
|
{tab === "restaurants" && <RestaurantsPanel isAdmin={isAdmin} />}
|
||||||
{tab === "users" && <UsersPanel />}
|
{tab === "users" && <UsersPanel />}
|
||||||
|
{tab === "daemon" && <DaemonPanel isAdmin={isAdmin} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 채널 관리 ─── */
|
/* ─── 채널 관리 ─── */
|
||||||
function ChannelsPanel() {
|
function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [newId, setNewId] = useState("");
|
const [newId, setNewId] = useState("");
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -101,7 +126,7 @@ function ChannelsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
{isAdmin && <div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
<h2 className="font-semibold mb-3">채널 추가</h2>
|
<h2 className="font-semibold mb-3">채널 추가</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -130,7 +155,7 @@ function ChannelsPanel() {
|
|||||||
추가
|
추가
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -139,7 +164,9 @@ function ChannelsPanel() {
|
|||||||
<th className="text-left px-4 py-3">채널 이름</th>
|
<th className="text-left px-4 py-3">채널 이름</th>
|
||||||
<th className="text-left px-4 py-3">Channel ID</th>
|
<th className="text-left px-4 py-3">Channel ID</th>
|
||||||
<th className="text-left px-4 py-3">제목 필터</th>
|
<th className="text-left px-4 py-3">제목 필터</th>
|
||||||
<th className="text-left px-4 py-3">액션</th>
|
<th className="text-right px-4 py-3">영상 수</th>
|
||||||
|
<th className="text-left px-4 py-3">마지막 스캔</th>
|
||||||
|
{isAdmin && <th className="text-left px-4 py-3">액션</th>}
|
||||||
<th className="text-left px-4 py-3">스캔 결과</th>
|
<th className="text-left px-4 py-3">스캔 결과</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -159,7 +186,17 @@ function ChannelsPanel() {
|
|||||||
<span className="text-gray-400 text-xs">전체</span>
|
<span className="text-gray-400 text-xs">전체</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 flex gap-3">
|
<td className="px-4 py-3 text-right font-medium">
|
||||||
|
{ch.video_count > 0 ? (
|
||||||
|
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}개</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">
|
||||||
|
{ch.last_scanned_at ? ch.last_scanned_at.slice(0, 16).replace("T", " ") : "-"}
|
||||||
|
</td>
|
||||||
|
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleScan(ch.channel_id)}
|
onClick={() => handleScan(ch.channel_id)}
|
||||||
className="text-blue-600 hover:underline text-sm"
|
className="text-blue-600 hover:underline text-sm"
|
||||||
@@ -178,7 +215,7 @@ function ChannelsPanel() {
|
|||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>}
|
||||||
<td className="px-4 py-3 text-gray-600">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
{scanResult[ch.channel_id] || "-"}
|
{scanResult[ch.channel_id] || "-"}
|
||||||
</td>
|
</td>
|
||||||
@@ -186,7 +223,7 @@ function ChannelsPanel() {
|
|||||||
))}
|
))}
|
||||||
{channels.length === 0 && (
|
{channels.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||||
등록된 채널이 없습니다
|
등록된 채널이 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -201,7 +238,7 @@ function ChannelsPanel() {
|
|||||||
/* ─── 영상 관리 ─── */
|
/* ─── 영상 관리 ─── */
|
||||||
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
|
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
|
||||||
|
|
||||||
function VideosPanel() {
|
function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [channelFilter, setChannelFilter] = useState("");
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
@@ -241,6 +278,10 @@ function VideosPanel() {
|
|||||||
const [manualAdding, setManualAdding] = useState(false);
|
const [manualAdding, setManualAdding] = useState(false);
|
||||||
const [bulkExtracting, setBulkExtracting] = useState(false);
|
const [bulkExtracting, setBulkExtracting] = useState(false);
|
||||||
const [bulkTranscripting, setBulkTranscripting] = useState(false);
|
const [bulkTranscripting, setBulkTranscripting] = useState(false);
|
||||||
|
const [rebuildingVectors, setRebuildingVectors] = useState(false);
|
||||||
|
const [vectorProgress, setVectorProgress] = useState<{ phase: string; current: number; total: number; name?: string } | null>(null);
|
||||||
|
const [remappingCuisine, setRemappingCuisine] = useState(false);
|
||||||
|
const [remapProgress, setRemapProgress] = useState<{ current: number; total: number; updated: number } | null>(null);
|
||||||
const [bulkProgress, setBulkProgress] = useState<{
|
const [bulkProgress, setBulkProgress] = useState<{
|
||||||
label: string;
|
label: string;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -256,7 +297,7 @@ function VideosPanel() {
|
|||||||
|
|
||||||
const load = useCallback((reset = true) => {
|
const load = useCallback((reset = true) => {
|
||||||
api
|
api
|
||||||
.getVideos({ status: statusFilter || undefined, limit: 500 })
|
.getVideos({ status: statusFilter || undefined })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setVideos(data);
|
setVideos(data);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@@ -375,7 +416,16 @@ function VideosPanel() {
|
|||||||
|
|
||||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||||
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
|
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
|
||||||
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST" });
|
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers });
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`);
|
||||||
|
setRunning(false);
|
||||||
|
setBulkProgress(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reader = resp.body?.getReader();
|
const reader = resp.body?.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
if (!reader) { setRunning(false); return; }
|
if (!reader) { setRunning(false); return; }
|
||||||
@@ -416,6 +466,96 @@ function VideosPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startRebuildVectors = async () => {
|
||||||
|
if (!confirm("전체 식당 벡터를 재생성합니다. 진행하시겠습니까?")) return;
|
||||||
|
setRebuildingVectors(true);
|
||||||
|
setVectorProgress(null);
|
||||||
|
try {
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||||
|
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
const resp = await fetch(`${apiBase}/api/videos/rebuild-vectors`, { method: "POST", headers });
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`벡터 재생성 실패: ${resp.status}`);
|
||||||
|
setRebuildingVectors(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = resp.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
if (!reader) { setRebuildingVectors(false); return; }
|
||||||
|
let buf = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop() || "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(line.slice(6));
|
||||||
|
if (ev.status === "progress" || ev.type === "progress") {
|
||||||
|
setVectorProgress({ phase: ev.phase, current: ev.current, total: ev.total, name: ev.name });
|
||||||
|
} else if (ev.status === "done" || ev.type === "done") {
|
||||||
|
setVectorProgress({ phase: "done", current: ev.total, total: ev.total });
|
||||||
|
} else if (ev.type === "error") {
|
||||||
|
alert(`벡터 재생성 오류: ${ev.message}`);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRebuildingVectors(false);
|
||||||
|
} catch {
|
||||||
|
setRebuildingVectors(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRemapCuisine = async () => {
|
||||||
|
if (!confirm("전체 식당의 음식 종류를 LLM으로 재분류합니다. 진행하시겠습니까?")) return;
|
||||||
|
setRemappingCuisine(true);
|
||||||
|
setRemapProgress(null);
|
||||||
|
try {
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||||
|
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
const resp = await fetch(`${apiBase}/api/videos/remap-cuisine`, { method: "POST", headers });
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`음식 종류 재분류 실패: ${resp.status}`);
|
||||||
|
setRemappingCuisine(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = resp.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
if (!reader) { setRemappingCuisine(false); return; }
|
||||||
|
let buf = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop() || "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(line.slice(6));
|
||||||
|
if (ev.type === "processing" || ev.type === "batch_done") {
|
||||||
|
setRemapProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
|
||||||
|
} else if (ev.type === "complete") {
|
||||||
|
setRemapProgress({ current: ev.total, total: ev.total, updated: ev.updated });
|
||||||
|
} else if (ev.type === "error") {
|
||||||
|
alert(`재분류 오류: ${ev.message}`);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRemappingCuisine(false);
|
||||||
|
} catch {
|
||||||
|
setRemappingCuisine(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = (key: VideoSortKey) => {
|
const handleSort = (key: VideoSortKey) => {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
setSortAsc(!sortAsc);
|
setSortAsc(!sortAsc);
|
||||||
@@ -512,7 +652,7 @@ function VideosPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setDetail(null); setEditingRestIdx(null); setEditRest(null); load(); }}
|
onClick={() => { setEditingRestIdx(null); setEditRest(null); load(false); if (detail) { api.getVideoDetail(detail.id).then(setDetail).catch(() => {}); } }}
|
||||||
className="border rounded-r px-3 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100 cursor-pointer relative z-10"
|
className="border rounded-r px-3 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100 cursor-pointer relative z-10"
|
||||||
title="새로고침"
|
title="새로고침"
|
||||||
>
|
>
|
||||||
@@ -520,6 +660,7 @@ function VideosPanel() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && <>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleProcess}
|
onClick={handleProcess}
|
||||||
@@ -542,10 +683,25 @@ function VideosPanel() {
|
|||||||
>
|
>
|
||||||
{bulkExtracting ? "벌크 추출 중..." : "벌크 LLM 추출"}
|
{bulkExtracting ? "벌크 추출 중..." : "벌크 LLM 추출"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={startRebuildVectors}
|
||||||
|
disabled={rebuildingVectors || bulkExtracting || bulkTranscripting}
|
||||||
|
className="bg-teal-600 text-white px-4 py-2 rounded text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{rebuildingVectors ? "벡터 재생성 중..." : "벡터 재생성"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={startRemapCuisine}
|
||||||
|
disabled={remappingCuisine || bulkExtracting || bulkTranscripting || rebuildingVectors}
|
||||||
|
className="bg-amber-600 text-white px-4 py-2 rounded text-sm hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{remappingCuisine ? "음식분류 중..." : "음식종류 재분류"}
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
{processResult && (
|
{processResult && (
|
||||||
<span className="text-sm text-gray-600">{processResult}</span>
|
<span className="text-sm text-gray-600">{processResult}</span>
|
||||||
)}
|
)}
|
||||||
{selected.size > 0 && (
|
{isAdmin && selected.size > 0 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkSkip}
|
onClick={handleBulkSkip}
|
||||||
@@ -605,7 +761,7 @@ function VideosPanel() {
|
|||||||
>
|
>
|
||||||
게시일{sortIcon("published_at")}
|
게시일{sortIcon("published_at")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-3 whitespace-nowrap">액션</th>
|
{isAdmin && <th className="text-left px-4 py-3 whitespace-nowrap">액션</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -629,12 +785,13 @@ function VideosPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600">{v.channel_name}</td>
|
<td className="px-4 py-3 text-gray-600">{v.channel_name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3 max-w-[300px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelectVideo(v)}
|
onClick={() => handleSelectVideo(v)}
|
||||||
className={`text-left text-sm hover:underline ${
|
className={`text-left text-sm hover:underline truncate block max-w-full ${
|
||||||
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-blue-600"
|
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-blue-600"
|
||||||
}`}
|
}`}
|
||||||
|
title={v.title}
|
||||||
>
|
>
|
||||||
{v.title}
|
{v.title}
|
||||||
</button>
|
</button>
|
||||||
@@ -668,7 +825,7 @@ function VideosPanel() {
|
|||||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">
|
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">
|
||||||
{v.published_at?.slice(0, 10) || "-"}
|
{v.published_at?.slice(0, 10) || "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap flex gap-3">
|
{isAdmin && <td className="px-4 py-3 whitespace-nowrap flex gap-3">
|
||||||
{v.status === "pending" && (
|
{v.status === "pending" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSkip(v.id)}
|
onClick={() => handleSkip(v.id)}
|
||||||
@@ -683,7 +840,7 @@ function VideosPanel() {
|
|||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{videos.length === 0 && (
|
{videos.length === 0 && (
|
||||||
@@ -733,6 +890,43 @@ function VideosPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 음식종류 재분류 진행 */}
|
||||||
|
{remapProgress && (
|
||||||
|
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||||
|
<h4 className="font-semibold text-sm mb-2">
|
||||||
|
음식종류 재분류 {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
|
||||||
|
</h4>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-amber-500 h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${remapProgress.total ? (remapProgress.current / remapProgress.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{remapProgress.current}/{remapProgress.total} — {remapProgress.updated}개 업데이트
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 벡터 재생성 진행 */}
|
||||||
|
{vectorProgress && (
|
||||||
|
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||||
|
<h4 className="font-semibold text-sm mb-2">
|
||||||
|
벡터 재생성 {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
|
||||||
|
</h4>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-teal-500 h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${vectorProgress.total ? (vectorProgress.current / vectorProgress.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{vectorProgress.current}/{vectorProgress.total}
|
||||||
|
{vectorProgress.name && ` — ${vectorProgress.name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 벌크 진행 패널 */}
|
{/* 벌크 진행 패널 */}
|
||||||
{bulkProgress && (
|
{bulkProgress && (
|
||||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||||
@@ -822,9 +1016,9 @@ function VideosPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold text-base cursor-pointer hover:text-blue-600"
|
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-blue-600" : ""}`}
|
||||||
onClick={() => { setEditTitle(detail.title); setEditingTitle(true); }}
|
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
|
||||||
title="클릭하여 제목 수정"
|
title={isAdmin ? "클릭하여 제목 수정" : undefined}
|
||||||
>
|
>
|
||||||
{detail.title}
|
{detail.title}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -871,7 +1065,7 @@ function VideosPanel() {
|
|||||||
<h4 className="font-semibold text-sm">
|
<h4 className="font-semibold text-sm">
|
||||||
추출된 식당 ({detail.restaurants.length})
|
추출된 식당 ({detail.restaurants.length})
|
||||||
</h4>
|
</h4>
|
||||||
{detail.transcript && (
|
{isAdmin && detail.transcript && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -909,12 +1103,12 @@ function VideosPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
{isAdmin && <button
|
||||||
onClick={() => setShowManualAdd(!showManualAdd)}
|
onClick={() => setShowManualAdd(!showManualAdd)}
|
||||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
|
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
|
||||||
>
|
>
|
||||||
{showManualAdd ? "수동 추가 닫기" : "수동 추가"}
|
{showManualAdd ? "수동 추가 닫기" : "수동 추가"}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
{showManualAdd && (
|
{showManualAdd && (
|
||||||
<div className="border rounded p-3 mb-3 bg-green-50 space-y-2">
|
<div className="border rounded p-3 mb-3 bg-green-50 space-y-2">
|
||||||
@@ -1064,7 +1258,7 @@ function VideosPanel() {
|
|||||||
setDetail(d);
|
setDetail(d);
|
||||||
setEditingRestIdx(null);
|
setEditingRestIdx(null);
|
||||||
setEditRest(null);
|
setEditRest(null);
|
||||||
} catch { alert("저장 실패"); }
|
} catch (e) { alert("저장 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}}
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -1082,8 +1276,8 @@ function VideosPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer hover:bg-gray-50 -m-3 p-3 rounded group"
|
className={`${isAdmin ? "cursor-pointer hover:bg-gray-50" : ""} -m-3 p-3 rounded group`}
|
||||||
onClick={() => {
|
onClick={isAdmin ? () => {
|
||||||
let evalText = "";
|
let evalText = "";
|
||||||
if (typeof r.evaluation === "object" && r.evaluation) {
|
if (typeof r.evaluation === "object" && r.evaluation) {
|
||||||
if (r.evaluation.text) {
|
if (r.evaluation.text) {
|
||||||
@@ -1107,8 +1301,8 @@ function VideosPanel() {
|
|||||||
price_range: r.price_range || "",
|
price_range: r.price_range || "",
|
||||||
guests: r.guests.join(", "),
|
guests: r.guests.join(", "),
|
||||||
});
|
});
|
||||||
}}
|
} : undefined}
|
||||||
title="클릭하여 수정"
|
title={isAdmin ? "클릭하여 수정" : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1121,7 +1315,7 @@ function VideosPanel() {
|
|||||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-600">미매칭</span>
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-600">미매칭</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isAdmin && <button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!confirm(`"${r.name}" 식당 매핑을 삭제하시겠습니까?`)) return;
|
if (!confirm(`"${r.name}" 식당 매핑을 삭제하시겠습니까?`)) return;
|
||||||
@@ -1135,7 +1329,7 @@ function VideosPanel() {
|
|||||||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs px-1.5 py-0.5 rounded hover:bg-red-50 transition-opacity"
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs px-1.5 py-0.5 rounded hover:bg-red-50 transition-opacity"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
||||||
{r.address && <p>주소: {r.address}</p>}
|
{r.address && <p>주소: {r.address}</p>}
|
||||||
@@ -1172,6 +1366,7 @@ function VideosPanel() {
|
|||||||
{editingRestIdx === null && <div>
|
{editingRestIdx === null && <div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h4 className="font-semibold text-sm">트랜스크립트</h4>
|
<h4 className="font-semibold text-sm">트랜스크립트</h4>
|
||||||
|
{isAdmin && <>
|
||||||
<select
|
<select
|
||||||
value={transcriptMode}
|
value={transcriptMode}
|
||||||
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
|
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
|
||||||
@@ -1200,6 +1395,7 @@ function VideosPanel() {
|
|||||||
>
|
>
|
||||||
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
|
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
|
||||||
</button>
|
</button>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
{detail.transcript ? (
|
{detail.transcript ? (
|
||||||
<pre className="text-xs text-gray-700 bg-gray-50 rounded p-3 whitespace-pre-wrap leading-relaxed max-h-[200px] overflow-y-auto">
|
<pre className="text-xs text-gray-700 bg-gray-50 rounded p-3 whitespace-pre-wrap leading-relaxed max-h-[200px] overflow-y-auto">
|
||||||
@@ -1218,7 +1414,7 @@ function VideosPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 식당 관리 ─── */
|
/* ─── 식당 관리 ─── */
|
||||||
function RestaurantsPanel() {
|
function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
|
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -1301,8 +1497,8 @@ function RestaurantsPanel() {
|
|||||||
await api.updateRestaurant(selected.id, data as Partial<Restaurant>);
|
await api.updateRestaurant(selected.id, data as Partial<Restaurant>);
|
||||||
load();
|
load();
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
alert("저장 실패");
|
alert("저장 실패: " + (e instanceof Error ? e.message : String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1439,6 +1635,7 @@ function RestaurantsPanel() {
|
|||||||
value={editForm[key] || ""}
|
value={editForm[key] || ""}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
className="w-full border rounded px-2 py-1.5 text-sm"
|
||||||
|
disabled={!isAdmin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1481,25 +1678,25 @@ function RestaurantsPanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<button
|
{isAdmin && <button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "저장 중..." : "저장"}
|
{saving ? "저장 중..." : "저장"}
|
||||||
</button>
|
</button>}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelected(null)}
|
onClick={() => setSelected(null)}
|
||||||
className="px-4 py-2 text-sm border rounded text-gray-600 hover:bg-gray-100"
|
className="px-4 py-2 text-sm border rounded text-gray-600 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
취소
|
{isAdmin ? "취소" : "닫기"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isAdmin && <button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="px-4 py-2 text-sm text-red-500 border border-red-200 rounded hover:bg-red-50 ml-auto"
|
className="px-4 py-2 text-sm text-red-500 border border-red-200 rounded hover:bg-red-50 ml-auto"
|
||||||
>
|
>
|
||||||
식당 삭제
|
식당 삭제
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1796,3 +1993,229 @@ function UsersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 데몬 설정 ─── */
|
||||||
|
function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const [config, setConfig] = useState<DaemonConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [running, setRunning] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Editable fields
|
||||||
|
const [scanEnabled, setScanEnabled] = useState(false);
|
||||||
|
const [scanInterval, setScanInterval] = useState(60);
|
||||||
|
const [processEnabled, setProcessEnabled] = useState(false);
|
||||||
|
const [processInterval, setProcessInterval] = useState(60);
|
||||||
|
const [processLimit, setProcessLimit] = useState(10);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.getDaemonConfig().then((cfg) => {
|
||||||
|
setConfig(cfg);
|
||||||
|
setScanEnabled(cfg.scan_enabled);
|
||||||
|
setScanInterval(cfg.scan_interval_min);
|
||||||
|
setProcessEnabled(cfg.process_enabled);
|
||||||
|
setProcessInterval(cfg.process_interval_min);
|
||||||
|
setProcessLimit(cfg.process_limit);
|
||||||
|
}).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
await api.updateDaemonConfig({
|
||||||
|
scan_enabled: scanEnabled,
|
||||||
|
scan_interval_min: scanInterval,
|
||||||
|
process_enabled: processEnabled,
|
||||||
|
process_interval_min: processInterval,
|
||||||
|
process_limit: processLimit,
|
||||||
|
});
|
||||||
|
setResult("설정 저장 완료");
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResult(e instanceof Error ? e.message : "저장 실패");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunScan = async () => {
|
||||||
|
setRunning("scan");
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.runDaemonScan();
|
||||||
|
setResult(`채널 스캔 완료: 신규 ${res.new_videos}개 영상`);
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResult(e instanceof Error ? e.message : "스캔 실패");
|
||||||
|
} finally {
|
||||||
|
setRunning(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunProcess = async () => {
|
||||||
|
setRunning("process");
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.runDaemonProcess(processLimit);
|
||||||
|
setResult(`영상 처리 완료: ${res.restaurants_extracted}개 식당 추출`);
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResult(e instanceof Error ? e.message : "처리 실패");
|
||||||
|
} finally {
|
||||||
|
setRunning(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="text-gray-500">로딩 중...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Schedule Config */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Scan config */}
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">채널 스캔</h3>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scanEnabled}
|
||||||
|
onChange={(e) => setScanEnabled(e.target.checked)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${scanEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||||
|
{scanEnabled ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={scanInterval}
|
||||||
|
onChange={(e) => setScanInterval(Number(e.target.value))}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
min={1}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{config?.last_scan_at && (
|
||||||
|
<p className="text-xs text-gray-400">마지막 스캔: {config.last_scan_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Process config */}
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">영상 처리</h3>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={processEnabled}
|
||||||
|
onChange={(e) => setProcessEnabled(e.target.checked)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${processEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||||
|
{processEnabled ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={processInterval}
|
||||||
|
onChange={(e) => setProcessInterval(Number(e.target.value))}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
min={1}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">처리 건수</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={processLimit}
|
||||||
|
onChange={(e) => setProcessLimit(Number(e.target.value))}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{config?.last_process_at && (
|
||||||
|
<p className="text-xs text-gray-400">마지막 처리: {config.last_process_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "저장 중..." : "설정 저장"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Triggers */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleRunScan}
|
||||||
|
disabled={running !== null}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running === "scan" ? "스캔 중..." : "채널 스캔 실행"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRunProcess}
|
||||||
|
disabled={running !== null}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running === "process" ? "처리 중..." : "영상 처리 실행"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<p className={`mt-3 text-sm ${result.includes("실패") || result.includes("API") ? "text-red-600" : "text-green-600"}`}>
|
||||||
|
{result}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config updated_at */}
|
||||||
|
{config?.updated_at && (
|
||||||
|
<p className="text-xs text-gray-400 text-right">설정 수정일: {config.updated_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,132 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { GoogleLogin } from "@react-oauth/google";
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Restaurant, Channel, Review } from "@/lib/api";
|
import type { Restaurant, Channel, Review } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import MapView from "@/components/MapView";
|
import MapView, { MapBounds, FlyTo } from "@/components/MapView";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
import RestaurantList from "@/components/RestaurantList";
|
import RestaurantList from "@/components/RestaurantList";
|
||||||
import RestaurantDetail from "@/components/RestaurantDetail";
|
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||||
import MyReviewsList from "@/components/MyReviewsList";
|
import MyReviewsList from "@/components/MyReviewsList";
|
||||||
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
|
|
||||||
|
const CUISINE_GROUPS: { label: string; prefix: string }[] = [
|
||||||
|
{ label: "한식", prefix: "한식" },
|
||||||
|
{ label: "일식", prefix: "일식" },
|
||||||
|
{ label: "중식", prefix: "중식" },
|
||||||
|
{ label: "양식", prefix: "양식" },
|
||||||
|
{ label: "아시아", prefix: "아시아" },
|
||||||
|
{ label: "기타", prefix: "기타" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchCuisineGroup(cuisineType: string | null, group: string): boolean {
|
||||||
|
if (!cuisineType) return false;
|
||||||
|
const g = CUISINE_GROUPS.find((g) => g.label === group);
|
||||||
|
if (!g) return false;
|
||||||
|
return cuisineType.startsWith(g.prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
|
||||||
|
{
|
||||||
|
label: "저렴 (~1만원)",
|
||||||
|
test: (p) => /저렴|가성비|착한|만원 이하|[3-9]천원|^\d[,.]?\d*천원/.test(p) || /^[1]만원대$/.test(p) || /^[5-9],?\d{3}원/.test(p),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "보통 (1~3만원)",
|
||||||
|
test: (p) => /[1-2]만원대|1-[23]만|인당 [12]\d?,?\d*원|1[2-9],?\d{3}원|2[0-9],?\d{3}원/.test(p),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "고가 (3만원~)",
|
||||||
|
test: (p) => /[3-9]만원|고가|높은|묵직|살벌|10만원|5만원|4만원|6만원/.test(p),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchPriceGroup(priceRange: string | null, group: string): boolean {
|
||||||
|
if (!priceRange) return false;
|
||||||
|
const g = PRICE_GROUPS.find((g) => g.label === group);
|
||||||
|
if (!g) return false;
|
||||||
|
return g.test(priceRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse pipe-delimited region "나라|시|구" into parts. */
|
||||||
|
function parseRegion(region: string | null): { country: string; city: string; district: string } | null {
|
||||||
|
if (!region) return null;
|
||||||
|
const parts = region.split("|");
|
||||||
|
return {
|
||||||
|
country: parts[0] || "",
|
||||||
|
city: parts[1] || "",
|
||||||
|
district: parts[2] || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build 3-level tree: country → city → district[] */
|
||||||
|
function buildRegionTree(restaurants: Restaurant[]) {
|
||||||
|
const tree = new Map<string, Map<string, Set<string>>>();
|
||||||
|
for (const r of restaurants) {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
if (!p || !p.country) continue;
|
||||||
|
if (!tree.has(p.country)) tree.set(p.country, new Map());
|
||||||
|
const cityMap = tree.get(p.country)!;
|
||||||
|
if (p.city) {
|
||||||
|
if (!cityMap.has(p.city)) cityMap.set(p.city, new Set());
|
||||||
|
if (p.district) cityMap.get(p.city)!.add(p.district);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute centroid + appropriate zoom from a set of restaurants. */
|
||||||
|
function computeFlyTo(rests: Restaurant[]): FlyTo | null {
|
||||||
|
if (rests.length === 0) return null;
|
||||||
|
const lat = rests.reduce((s, r) => s + r.latitude, 0) / rests.length;
|
||||||
|
const lng = rests.reduce((s, r) => s + r.longitude, 0) / rests.length;
|
||||||
|
// Pick zoom based on geographic spread
|
||||||
|
const latSpread = Math.max(...rests.map((r) => r.latitude)) - Math.min(...rests.map((r) => r.latitude));
|
||||||
|
const lngSpread = Math.max(...rests.map((r) => r.longitude)) - Math.min(...rests.map((r) => r.longitude));
|
||||||
|
const spread = Math.max(latSpread, lngSpread);
|
||||||
|
let zoom = 13;
|
||||||
|
if (spread > 2) zoom = 8;
|
||||||
|
else if (spread > 1) zoom = 9;
|
||||||
|
else if (spread > 0.5) zoom = 10;
|
||||||
|
else if (spread > 0.2) zoom = 11;
|
||||||
|
else if (spread > 0.1) zoom = 12;
|
||||||
|
else if (spread > 0.02) zoom = 14;
|
||||||
|
else zoom = 15;
|
||||||
|
return { lat, lng, zoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find best matching country + city from user's coordinates using restaurant data. */
|
||||||
|
function findRegionFromCoords(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
restaurants: Restaurant[],
|
||||||
|
): { country: string; city: string } | null {
|
||||||
|
// Group restaurants by country|city and compute centroids
|
||||||
|
const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>();
|
||||||
|
for (const r of restaurants) {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
if (!p || !p.country || !p.city) continue;
|
||||||
|
const key = `${p.country}|${p.city}`;
|
||||||
|
if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] });
|
||||||
|
const g = groups.get(key)!;
|
||||||
|
g.lats.push(r.latitude);
|
||||||
|
g.lngs.push(r.longitude);
|
||||||
|
}
|
||||||
|
let best: { country: string; city: string } | null = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length;
|
||||||
|
const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length;
|
||||||
|
const dist = (cLat - lat) ** 2 + (cLng - lng) ** 2;
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = { country: g.country, city: g.city };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, login, logout, isLoading: authLoading } = useAuth();
|
const { user, login, logout, isLoading: authLoading } = useAuth();
|
||||||
@@ -19,10 +136,60 @@ export default function Home() {
|
|||||||
const [showDetail, setShowDetail] = useState(false);
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [channelFilter, setChannelFilter] = useState("");
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
|
const [cuisineFilter, setCuisineFilter] = useState("");
|
||||||
|
const [priceFilter, setPriceFilter] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"map" | "list">("list");
|
||||||
|
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||||
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
|
const [boundsFilterOn, setBoundsFilterOn] = useState(false);
|
||||||
|
const [countryFilter, setCountryFilter] = useState("");
|
||||||
|
const [cityFilter, setCityFilter] = useState("");
|
||||||
|
const [districtFilter, setDistrictFilter] = useState("");
|
||||||
|
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
|
||||||
const [showFavorites, setShowFavorites] = useState(false);
|
const [showFavorites, setShowFavorites] = useState(false);
|
||||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||||
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
||||||
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
||||||
|
const geoApplied = useRef(false);
|
||||||
|
|
||||||
|
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||||
|
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
|
||||||
|
const cities = useMemo(() => {
|
||||||
|
if (!countryFilter) return [];
|
||||||
|
const cityMap = regionTree.get(countryFilter);
|
||||||
|
return cityMap ? [...cityMap.keys()].sort() : [];
|
||||||
|
}, [regionTree, countryFilter]);
|
||||||
|
const districts = useMemo(() => {
|
||||||
|
if (!countryFilter || !cityFilter) return [];
|
||||||
|
const cityMap = regionTree.get(countryFilter);
|
||||||
|
if (!cityMap) return [];
|
||||||
|
const set = cityMap.get(cityFilter);
|
||||||
|
return set ? [...set].sort() : [];
|
||||||
|
}, [regionTree, countryFilter, cityFilter]);
|
||||||
|
|
||||||
|
const filteredRestaurants = useMemo(() => {
|
||||||
|
return restaurants.filter((r) => {
|
||||||
|
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||||
|
if (cuisineFilter && !matchCuisineGroup(r.cuisine_type, cuisineFilter)) return false;
|
||||||
|
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
|
||||||
|
if (countryFilter) {
|
||||||
|
const parsed = parseRegion(r.region);
|
||||||
|
if (!parsed || parsed.country !== countryFilter) return false;
|
||||||
|
if (cityFilter && parsed.city !== cityFilter) return false;
|
||||||
|
if (districtFilter && parsed.district !== districtFilter) return false;
|
||||||
|
}
|
||||||
|
if (boundsFilterOn && mapBounds) {
|
||||||
|
if (r.latitude < mapBounds.south || r.latitude > mapBounds.north) return false;
|
||||||
|
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds]);
|
||||||
|
|
||||||
|
// Set desktop default to map mode on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.innerWidth >= 768) setViewMode("map");
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load channels + record visit on mount
|
// Load channels + record visit on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,11 +200,35 @@ export default function Home() {
|
|||||||
// Load restaurants on mount and when channel filter changes
|
// Load restaurants on mount and when channel filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.getRestaurants({ limit: 200, channel: channelFilter || undefined })
|
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||||
.then(setRestaurants)
|
.then(setRestaurants)
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [channelFilter]);
|
}, [channelFilter]);
|
||||||
|
|
||||||
|
// Auto-select region from user's geolocation (once)
|
||||||
|
useEffect(() => {
|
||||||
|
if (geoApplied.current || restaurants.length === 0) return;
|
||||||
|
if (!navigator.geolocation) return;
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
if (geoApplied.current) return;
|
||||||
|
geoApplied.current = true;
|
||||||
|
const match = findRegionFromCoords(pos.coords.latitude, pos.coords.longitude, restaurants);
|
||||||
|
if (match) {
|
||||||
|
setCountryFilter(match.country);
|
||||||
|
setCityFilter(match.city);
|
||||||
|
const matched = restaurants.filter((r) => {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
return p && p.country === match.country && p.city === match.city;
|
||||||
|
});
|
||||||
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => { /* user denied or error — do nothing */ },
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
}, [restaurants]);
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
async (query: string, mode: "keyword" | "semantic" | "hybrid") => {
|
async (query: string, mode: "keyword" | "semantic" | "hybrid") => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -64,13 +255,72 @@ export default function Home() {
|
|||||||
setShowDetail(false);
|
setShowDetail(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleBoundsChanged = useCallback((bounds: MapBounds) => {
|
||||||
|
setMapBounds(bounds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCountryChange = useCallback((country: string) => {
|
||||||
|
setCountryFilter(country);
|
||||||
|
setCityFilter("");
|
||||||
|
setDistrictFilter("");
|
||||||
|
if (!country) { setRegionFlyTo(null); return; }
|
||||||
|
const matched = restaurants.filter((r) => {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
return p && p.country === country;
|
||||||
|
});
|
||||||
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
|
}, [restaurants]);
|
||||||
|
|
||||||
|
const handleCityChange = useCallback((city: string) => {
|
||||||
|
setCityFilter(city);
|
||||||
|
setDistrictFilter("");
|
||||||
|
if (!city) {
|
||||||
|
// Re-fly to country level
|
||||||
|
const matched = restaurants.filter((r) => {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
return p && p.country === countryFilter;
|
||||||
|
});
|
||||||
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matched = restaurants.filter((r) => {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
return p && p.country === countryFilter && p.city === city;
|
||||||
|
});
|
||||||
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
|
}, [restaurants, countryFilter]);
|
||||||
|
|
||||||
|
const handleDistrictChange = useCallback((district: string) => {
|
||||||
|
setDistrictFilter(district);
|
||||||
|
if (!district) {
|
||||||
|
const matched = restaurants.filter((r) => {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
return p && p.country === countryFilter && p.city === cityFilter;
|
||||||
|
});
|
||||||
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matched = restaurants.filter((r) => {
|
||||||
|
const p = parseRegion(r.region);
|
||||||
|
return p && p.country === countryFilter && p.city === cityFilter && p.district === district;
|
||||||
|
});
|
||||||
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
|
}, [restaurants, countryFilter, cityFilter]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setChannelFilter("");
|
setChannelFilter("");
|
||||||
|
setCuisineFilter("");
|
||||||
|
setPriceFilter("");
|
||||||
|
setCountryFilter("");
|
||||||
|
setCityFilter("");
|
||||||
|
setDistrictFilter("");
|
||||||
|
setRegionFlyTo(null);
|
||||||
|
setBoundsFilterOn(false);
|
||||||
setShowFavorites(false);
|
setShowFavorites(false);
|
||||||
setShowMyReviews(false);
|
setShowMyReviews(false);
|
||||||
api
|
api
|
||||||
.getRestaurants({ limit: 200 })
|
.getRestaurants({ limit: 500 })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setRestaurants(data);
|
setRestaurants(data);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -83,7 +333,7 @@ export default function Home() {
|
|||||||
const handleToggleFavorites = async () => {
|
const handleToggleFavorites = async () => {
|
||||||
if (showFavorites) {
|
if (showFavorites) {
|
||||||
setShowFavorites(false);
|
setShowFavorites(false);
|
||||||
const data = await api.getRestaurants({ limit: 200, channel: channelFilter || undefined });
|
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||||
setRestaurants(data);
|
setRestaurants(data);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +364,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Desktop sidebar: shows detail inline
|
||||||
const sidebarContent = showMyReviews ? (
|
const sidebarContent = showMyReviews ? (
|
||||||
<MyReviewsList
|
<MyReviewsList
|
||||||
reviews={myReviews}
|
reviews={myReviews}
|
||||||
@@ -134,7 +385,29 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RestaurantList
|
<RestaurantList
|
||||||
restaurants={restaurants}
|
restaurants={filteredRestaurants}
|
||||||
|
selectedId={selected?.id}
|
||||||
|
onSelect={handleSelectRestaurant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mobile list: always shows list (detail goes to bottom sheet)
|
||||||
|
const mobileListContent = showMyReviews ? (
|
||||||
|
<MyReviewsList
|
||||||
|
reviews={myReviews}
|
||||||
|
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
||||||
|
onSelectRestaurant={async (restaurantId) => {
|
||||||
|
try {
|
||||||
|
const r = await api.getRestaurant(restaurantId);
|
||||||
|
handleSelectRestaurant(r);
|
||||||
|
setShowMyReviews(false);
|
||||||
|
setMyReviews([]);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RestaurantList
|
||||||
|
restaurants={filteredRestaurants}
|
||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
onSelect={handleSelectRestaurant}
|
onSelect={handleSelectRestaurant}
|
||||||
/>
|
/>
|
||||||
@@ -149,62 +422,136 @@ export default function Home() {
|
|||||||
Tasteby
|
Tasteby
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Desktop: search inline */}
|
{/* Desktop: search + filters — two rows */}
|
||||||
<div className="hidden md:block flex-1 max-w-xl mx-4">
|
<div className="hidden md:flex flex-col gap-1.5 mx-4">
|
||||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
{/* Row 1: Search + dropdown filters */}
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-96 shrink-0">
|
||||||
{/* Desktop: filters inline */}
|
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||||
<div className="hidden md:flex items-center gap-2">
|
</div>
|
||||||
<select
|
<select
|
||||||
value={channelFilter}
|
value={channelFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setChannelFilter(e.target.value);
|
setChannelFilter(e.target.value);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setShowDetail(false);
|
setShowDetail(false);
|
||||||
}}
|
}}
|
||||||
className="border rounded px-2 py-1.5 text-sm text-gray-600"
|
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">전체 채널</option>
|
<option value="">전체 채널</option>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
<option key={ch.id} value={ch.channel_name}>
|
<option key={ch.id} value={ch.channel_name}>
|
||||||
{ch.channel_name}
|
{ch.channel_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{user && (
|
<select
|
||||||
<>
|
value={cuisineFilter}
|
||||||
<button
|
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||||
onClick={handleToggleFavorites}
|
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
>
|
||||||
showFavorites
|
<option value="">전체 장르</option>
|
||||||
? "bg-red-50 border-red-300 text-red-600"
|
{CUISINE_GROUPS.map((g) => (
|
||||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
<option key={g.label} value={g.label}>{g.label}</option>
|
||||||
}`}
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={priceFilter}
|
||||||
|
onChange={(e) => setPriceFilter(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">전체 가격</option>
|
||||||
|
{PRICE_GROUPS.map((g) => (
|
||||||
|
<option key={g.label} value={g.label}>{g.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={countryFilter}
|
||||||
|
onChange={(e) => handleCountryChange(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">전체 나라</option>
|
||||||
|
{countries.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{countryFilter && cities.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={(e) => handleCityChange(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||||
>
|
>
|
||||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
<option value="">전체 시/도</option>
|
||||||
</button>
|
{cities.map((c) => (
|
||||||
<button
|
<option key={c} value={c}>{c}</option>
|
||||||
onClick={handleToggleMyReviews}
|
))}
|
||||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
</select>
|
||||||
showMyReviews
|
)}
|
||||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
{cityFilter && districts.length > 0 && (
|
||||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
<select
|
||||||
}`}
|
value={districtFilter}
|
||||||
|
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||||
>
|
>
|
||||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
<option value="">전체 구/군</option>
|
||||||
</button>
|
{districts.map((d) => (
|
||||||
</>
|
<option key={d} value={d}>{d}</option>
|
||||||
)}
|
))}
|
||||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
</select>
|
||||||
{restaurants.length}개
|
)}
|
||||||
</span>
|
</div>
|
||||||
|
{/* Row 2: Toggle buttons + count */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||||
|
className={`px-2.5 py-1 text-sm border rounded transition-colors ${
|
||||||
|
boundsFilterOn
|
||||||
|
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||||
|
: "hover:bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
title="지도 영역 내 식당만 표시"
|
||||||
|
>
|
||||||
|
{boundsFilterOn ? "📍 영역" : "📍"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||||
|
className="px-2.5 py-1 text-sm border rounded transition-colors hover:bg-gray-100 text-gray-600"
|
||||||
|
title={viewMode === "map" ? "리스트 우선" : "지도 우선"}
|
||||||
|
>
|
||||||
|
{viewMode === "map" ? "🗺" : "☰"}
|
||||||
|
</button>
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleFavorites}
|
||||||
|
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||||
|
showFavorites
|
||||||
|
? "bg-red-50 border-red-300 text-red-600"
|
||||||
|
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleMyReviews}
|
||||||
|
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||||
|
showMyReviews
|
||||||
|
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||||
|
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||||
|
{filteredRestaurants.length}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-4 shrink-0 hidden md:block" />
|
|
||||||
|
|
||||||
{/* User area */}
|
{/* User area */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0 flex items-center gap-3 ml-auto">
|
||||||
{authLoading ? null : user ? (
|
{authLoading ? null : user ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{user.avatar_url ? (
|
{user.avatar_url ? (
|
||||||
@@ -242,54 +589,160 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Header row 2 (mobile only): search + filters ── */}
|
{/* ── Header row 2 (mobile only): search + toolbar ── */}
|
||||||
<div className="md:hidden px-4 pb-2 space-y-2">
|
<div className="md:hidden px-4 pb-2 space-y-1.5">
|
||||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto">
|
<div className="flex-1">
|
||||||
<select
|
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||||
value={channelFilter}
|
</div>
|
||||||
onChange={(e) => {
|
<button
|
||||||
setChannelFilter(e.target.value);
|
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||||
setSelected(null);
|
className={`px-2 py-1.5 text-xs border rounded transition-colors shrink-0 ${
|
||||||
setShowDetail(false);
|
viewMode === "map"
|
||||||
}}
|
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||||
className="border rounded px-2 py-1 text-xs text-gray-600 shrink-0"
|
: "text-gray-600"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">전체 채널</option>
|
{viewMode === "map" ? "🗺" : "☰"}
|
||||||
{channels.map((ch) => (
|
</button>
|
||||||
<option key={ch.id} value={ch.channel_name}>
|
<button
|
||||||
{ch.channel_name}
|
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||||
</option>
|
className={`px-2 py-1.5 text-xs border rounded transition-colors shrink-0 relative ${
|
||||||
))}
|
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn
|
||||||
</select>
|
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||||
{user && (
|
: "text-gray-600"
|
||||||
<>
|
}`}
|
||||||
<button
|
>
|
||||||
onClick={handleToggleFavorites}
|
{showMobileFilters ? "✕" : "▽"} 필터
|
||||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
|
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn) && (
|
||||||
showFavorites
|
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-blue-500 text-white rounded-full text-[9px] flex items-center justify-center">
|
||||||
? "bg-red-50 border-red-300 text-red-600"
|
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
|
||||||
: "border-gray-300 text-gray-600"
|
</span>
|
||||||
}`}
|
)}
|
||||||
>
|
</button>
|
||||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
</button>
|
{filteredRestaurants.length}개
|
||||||
<button
|
|
||||||
onClick={handleToggleMyReviews}
|
|
||||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
|
|
||||||
showMyReviews
|
|
||||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
|
||||||
: "border-gray-300 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-gray-400 shrink-0 ml-1">
|
|
||||||
{restaurants.length}개
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible filter panel */}
|
||||||
|
{showMobileFilters && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 space-y-2 border">
|
||||||
|
{/* Dropdown filters */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={channelFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChannelFilter(e.target.value);
|
||||||
|
setSelected(null);
|
||||||
|
setShowDetail(false);
|
||||||
|
}}
|
||||||
|
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">전체 채널</option>
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<option key={ch.id} value={ch.channel_name}>
|
||||||
|
{ch.channel_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={cuisineFilter}
|
||||||
|
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">전체 장르</option>
|
||||||
|
{CUISINE_GROUPS.map((g) => (
|
||||||
|
<option key={g.label} value={g.label}>{g.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={priceFilter}
|
||||||
|
onChange={(e) => setPriceFilter(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">전체 가격</option>
|
||||||
|
{PRICE_GROUPS.map((g) => (
|
||||||
|
<option key={g.label} value={g.label}>{g.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Region filters */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={countryFilter}
|
||||||
|
onChange={(e) => handleCountryChange(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">전체 나라</option>
|
||||||
|
{countries.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{countryFilter && cities.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={(e) => handleCityChange(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">전체 시/도</option>
|
||||||
|
{cities.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{cityFilter && districts.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={districtFilter}
|
||||||
|
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">전체 구/군</option>
|
||||||
|
{districts.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Toggle buttons */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||||
|
className={`px-2 py-1 text-xs border rounded transition-colors ${
|
||||||
|
boundsFilterOn
|
||||||
|
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||||
|
: "text-gray-600 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{boundsFilterOn ? "📍 영역 ON" : "📍 영역"}
|
||||||
|
</button>
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleFavorites}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||||
|
showFavorites
|
||||||
|
? "bg-red-50 border-red-300 text-red-600"
|
||||||
|
: "border-gray-300 text-gray-600 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleMyReviews}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||||
|
showMyReviews
|
||||||
|
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||||
|
: "border-gray-300 text-gray-600 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -297,42 +750,101 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Desktop layout */}
|
{/* Desktop layout */}
|
||||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||||
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
{viewMode === "map" ? (
|
||||||
{sidebarContent}
|
<>
|
||||||
</aside>
|
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
||||||
<main className="flex-1 relative">
|
{sidebarContent}
|
||||||
<MapView
|
</aside>
|
||||||
restaurants={restaurants}
|
<main className="flex-1 relative">
|
||||||
selected={selected}
|
<MapView
|
||||||
onSelectRestaurant={handleSelectRestaurant}
|
restaurants={filteredRestaurants}
|
||||||
/>
|
selected={selected}
|
||||||
{visits && (
|
onSelectRestaurant={handleSelectRestaurant}
|
||||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
onBoundsChanged={handleBoundsChanged}
|
||||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
flyTo={regionFlyTo}
|
||||||
</div>
|
/>
|
||||||
)}
|
{visits && (
|
||||||
</main>
|
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||||
|
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<aside className="flex-1 bg-white overflow-y-auto">
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
<main className="w-[40%] shrink-0 relative border-l">
|
||||||
|
<MapView
|
||||||
|
restaurants={filteredRestaurants}
|
||||||
|
selected={selected}
|
||||||
|
onSelectRestaurant={handleSelectRestaurant}
|
||||||
|
onBoundsChanged={handleBoundsChanged}
|
||||||
|
flyTo={regionFlyTo}
|
||||||
|
/>
|
||||||
|
{visits && (
|
||||||
|
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||||
|
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile layout */}
|
{/* Mobile layout */}
|
||||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
|
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Map: fixed height */}
|
{viewMode === "map" ? (
|
||||||
<div className="h-[40vh] shrink-0 relative">
|
<>
|
||||||
<MapView
|
<div className="flex-1 relative">
|
||||||
restaurants={restaurants}
|
<MapView
|
||||||
selected={selected}
|
restaurants={filteredRestaurants}
|
||||||
onSelectRestaurant={handleSelectRestaurant}
|
selected={selected}
|
||||||
/>
|
onSelectRestaurant={handleSelectRestaurant}
|
||||||
{visits && (
|
onBoundsChanged={handleBoundsChanged}
|
||||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
flyTo={regionFlyTo}
|
||||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
/>
|
||||||
|
{visits && (
|
||||||
|
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||||
|
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 bg-white overflow-y-auto">
|
||||||
|
{mobileListContent}
|
||||||
|
{/* Scroll-down hint to reveal map */}
|
||||||
|
<div className="flex flex-col items-center py-4 text-gray-300">
|
||||||
|
<span className="text-lg">▼</span>
|
||||||
|
<span className="text-[10px]">아래로 스크롤하면 지도</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[35vh] shrink-0 relative border-t">
|
||||||
|
<MapView
|
||||||
|
restaurants={filteredRestaurants}
|
||||||
|
selected={selected}
|
||||||
|
onSelectRestaurant={handleSelectRestaurant}
|
||||||
|
onBoundsChanged={handleBoundsChanged}
|
||||||
|
flyTo={regionFlyTo}
|
||||||
|
/>
|
||||||
|
{visits && (
|
||||||
|
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||||
|
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Bottom Sheet for restaurant detail */}
|
||||||
|
<BottomSheet open={showDetail && !!selected} onClose={handleCloseDetail}>
|
||||||
|
{selected && (
|
||||||
|
<RestaurantDetail restaurant={selected} onClose={handleCloseDetail} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</BottomSheet>
|
||||||
{/* List/Detail: scrollable below */}
|
|
||||||
<div className="flex-1 bg-white border-t overflow-y-auto">
|
|
||||||
{sidebarContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
116
frontend/src/components/BottomSheet.tsx
Normal file
116
frontend/src/components/BottomSheet.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface BottomSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAP_POINTS = { PEEK: 0.4, HALF: 0.55, FULL: 0.92 };
|
||||||
|
const VELOCITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
export default function BottomSheet({ open, onClose, children }: BottomSheetProps) {
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState(SNAP_POINTS.PEEK);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragState = useRef({ startY: 0, startH: 0, lastY: 0, lastTime: 0 });
|
||||||
|
|
||||||
|
// Reset to peek when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setHeight(SNAP_POINTS.PEEK);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const snapTo = useCallback((h: number, velocity: number) => {
|
||||||
|
// If fast downward swipe, close
|
||||||
|
if (velocity > VELOCITY_THRESHOLD && h < SNAP_POINTS.HALF) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Snap to nearest point
|
||||||
|
const points = [SNAP_POINTS.PEEK, SNAP_POINTS.HALF, SNAP_POINTS.FULL];
|
||||||
|
let best = points[0];
|
||||||
|
let bestDist = Math.abs(h - best);
|
||||||
|
for (const p of points) {
|
||||||
|
const d = Math.abs(h - p);
|
||||||
|
if (d < bestDist) { best = p; bestDist = d; }
|
||||||
|
}
|
||||||
|
// If dragged below peek, close
|
||||||
|
if (h < SNAP_POINTS.PEEK * 0.6) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHeight(best);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
// Don't intercept if scrolling inside content that has scrollable area
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (content && content.scrollTop > 0 && height >= SNAP_POINTS.FULL - 0.05) return;
|
||||||
|
|
||||||
|
const y = e.touches[0].clientY;
|
||||||
|
dragState.current = { startY: y, startH: height, lastY: y, lastTime: Date.now() };
|
||||||
|
setDragging(true);
|
||||||
|
}, [height]);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const y = e.touches[0].clientY;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const deltaRatio = (dragState.current.startY - y) / vh;
|
||||||
|
const newH = Math.max(0.1, Math.min(SNAP_POINTS.FULL, dragState.current.startH + deltaRatio));
|
||||||
|
setHeight(newH);
|
||||||
|
dragState.current.lastY = y;
|
||||||
|
dragState.current.lastTime = Date.now();
|
||||||
|
}, [dragging]);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (!dragging) return;
|
||||||
|
setDragging(false);
|
||||||
|
const dt = (Date.now() - dragState.current.lastTime) / 1000 || 0.1;
|
||||||
|
const dy = (dragState.current.startY - dragState.current.lastY) / window.innerHeight;
|
||||||
|
const velocity = -dy / dt; // positive = downward
|
||||||
|
snapTo(height, velocity);
|
||||||
|
}, [dragging, height, snapTo]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/20 md:hidden"
|
||||||
|
style={{ opacity: Math.min(1, (height - 0.2) * 2) }}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white rounded-t-2xl shadow-2xl"
|
||||||
|
style={{
|
||||||
|
height: `${height * 100}vh`,
|
||||||
|
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
}}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Handle bar */}
|
||||||
|
<div className="flex justify-center pt-2 pb-1 shrink-0 cursor-grab">
|
||||||
|
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="flex-1 overflow-y-auto overscroll-contain"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useMap,
|
useMap,
|
||||||
} from "@vis.gl/react-google-maps";
|
} from "@vis.gl/react-google-maps";
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
|
||||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||||
@@ -37,16 +38,52 @@ function getChannelColorMap(restaurants: Restaurant[]) {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapBounds {
|
||||||
|
north: number;
|
||||||
|
south: number;
|
||||||
|
east: number;
|
||||||
|
west: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlyTo {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
selected?: Restaurant | null;
|
selected?: Restaurant | null;
|
||||||
onSelectRestaurant?: (r: Restaurant) => void;
|
onSelectRestaurant?: (r: Restaurant) => void;
|
||||||
|
onBoundsChanged?: (bounds: MapBounds) => void;
|
||||||
|
flyTo?: FlyTo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
|
function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
|
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Report bounds on idle (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const listener = map.addListener("idle", () => {
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
boundsTimerRef.current = setTimeout(() => {
|
||||||
|
const b = map.getBounds();
|
||||||
|
if (b && onBoundsChanged) {
|
||||||
|
const ne = b.getNorthEast();
|
||||||
|
const sw = b.getSouthWest();
|
||||||
|
onBoundsChanged({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
google.maps.event.removeListener(listener);
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [map, onBoundsChanged]);
|
||||||
|
|
||||||
const handleMarkerClick = useCallback(
|
const handleMarkerClick = useCallback(
|
||||||
(r: Restaurant) => {
|
(r: Restaurant) => {
|
||||||
@@ -56,6 +93,13 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
|||||||
[onSelectRestaurant]
|
[onSelectRestaurant]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fly to a specific location (region filter)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !flyTo) return;
|
||||||
|
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
||||||
|
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
||||||
|
}, [map, flyTo]);
|
||||||
|
|
||||||
// Pan and zoom to selected restaurant
|
// Pan and zoom to selected restaurant
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !selected) return;
|
if (!map || !selected) return;
|
||||||
@@ -98,6 +142,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
|||||||
textDecoration: isClosed ? "line-through" : "none",
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
{r.name}
|
{r.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -122,7 +167,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
|||||||
>
|
>
|
||||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{infoTarget.name}</h3>
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{getCuisineIcon(infoTarget.cuisine_type)} {infoTarget.name}</h3>
|
||||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +208,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
|
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
||||||
|
|
||||||
@@ -180,6 +225,8 @@ export default function MapView({ restaurants, selected, onSelectRestaurant }: M
|
|||||||
restaurants={restaurants}
|
restaurants={restaurants}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelectRestaurant={onSelectRestaurant}
|
onSelectRestaurant={onSelectRestaurant}
|
||||||
|
onBoundsChanged={onBoundsChanged}
|
||||||
|
flyTo={flyTo}
|
||||||
/>
|
/>
|
||||||
</Map>
|
</Map>
|
||||||
{channelNames.length > 1 && (
|
{channelNames.length > 1 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
|
||||||
interface RestaurantListProps {
|
interface RestaurantListProps {
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
@@ -31,7 +32,10 @@ export default function RestaurantList({
|
|||||||
selectedId === r.id ? "bg-blue-50 border-l-2 border-blue-500" : ""
|
selectedId === r.id ? "bg-blue-50 border-l-2 border-blue-500" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<h4 className="font-medium text-sm">{r.name}</h4>
|
<h4 className="font-medium text-sm">
|
||||||
|
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
|
{r.name}
|
||||||
|
</h4>
|
||||||
<div className="flex gap-2 mt-1 text-xs text-gray-500">
|
<div className="flex gap-2 mt-1 text-xs text-gray-500">
|
||||||
{r.cuisine_type && <span>{r.cuisine_type}</span>}
|
{r.cuisine_type && <span>{r.cuisine_type}</span>}
|
||||||
{r.region && <span>{r.region}</span>}
|
{r.region && <span>{r.region}</span>}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface Channel {
|
|||||||
channel_id: string;
|
channel_id: string;
|
||||||
channel_name: string;
|
channel_name: string;
|
||||||
title_filter: string | null;
|
title_filter: string | null;
|
||||||
|
video_count: number;
|
||||||
|
last_scanned_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Video {
|
export interface Video {
|
||||||
@@ -107,6 +109,7 @@ export interface User {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Review {
|
export interface Review {
|
||||||
@@ -120,6 +123,17 @@ export interface Review {
|
|||||||
user_avatar_url: 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 {
|
export interface ReviewsResponse {
|
||||||
reviews: Review[];
|
reviews: Review[];
|
||||||
avg_rating: number | null;
|
avg_rating: number | null;
|
||||||
@@ -428,4 +442,29 @@ export const api = {
|
|||||||
{ method: "PUT", body: JSON.stringify(data) }
|
{ 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" }
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
49
frontend/src/lib/cuisine-icons.ts
Normal file
49
frontend/src/lib/cuisine-icons.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Cuisine type → icon mapping.
|
||||||
|
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CUISINE_ICON_MAP: Record<string, string> = {
|
||||||
|
"한식": "🍚",
|
||||||
|
"일식": "🍣",
|
||||||
|
"중식": "🥟",
|
||||||
|
"양식": "🍝",
|
||||||
|
"아시아": "🍜",
|
||||||
|
"기타": "🍴",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sub-category overrides for more specific icons
|
||||||
|
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
||||||
|
{ keyword: "회/횟집", icon: "🐟" },
|
||||||
|
{ keyword: "해산물", icon: "🦐" },
|
||||||
|
{ keyword: "삼겹살/돼지구이", icon: "🥩" },
|
||||||
|
{ keyword: "소고기/한우구이", icon: "🥩" },
|
||||||
|
{ keyword: "곱창/막창", icon: "🥩" },
|
||||||
|
{ keyword: "닭/오리구이", icon: "🍗" },
|
||||||
|
{ keyword: "스테이크", icon: "🥩" },
|
||||||
|
{ keyword: "햄버거", icon: "🍔" },
|
||||||
|
{ keyword: "피자", icon: "🍕" },
|
||||||
|
{ keyword: "카페/디저트", icon: "☕" },
|
||||||
|
{ keyword: "베이커리", icon: "🥐" },
|
||||||
|
{ keyword: "치킨", icon: "🍗" },
|
||||||
|
{ keyword: "주점/포차", icon: "🍺" },
|
||||||
|
{ keyword: "이자카야", icon: "🍶" },
|
||||||
|
{ keyword: "라멘", icon: "🍜" },
|
||||||
|
{ keyword: "국밥/해장국", icon: "🍲" },
|
||||||
|
{ keyword: "분식", icon: "🍜" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ICON = "🍴";
|
||||||
|
|
||||||
|
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
|
if (!cuisineType) return DEFAULT_ICON;
|
||||||
|
|
||||||
|
// Check sub-category first
|
||||||
|
for (const rule of SUB_ICON_RULES) {
|
||||||
|
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to main category (prefix before |)
|
||||||
|
const main = cuisineType.split("|")[0];
|
||||||
|
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
||||||
|
}
|
||||||
@@ -43,5 +43,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
proxy_connect_timeout 75;
|
||||||
|
proxy_send_timeout 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user