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:
joungmin
2026-03-09 10:54:28 +09:00
parent 3694730501
commit 2bddb0f764
16 changed files with 2277 additions and 308 deletions

View 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}

View File

@@ -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"], [])

View File

@@ -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,15 +697,19 @@ 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:
cur = c.cursor()
cur.execute("UPDATE restaurants SET name = :name, updated_at = SYSTIMESTAMP WHERE id = :rid",
{"name": new_name, "rid": restaurant_id})
else:
new_rid = rest_mod.upsert( new_rid = rest_mod.upsert(
name=new_name, name=new_name,
address=geo.get("formatted_address") or body.get("address"), address=geo.get("formatted_address") or body.get("address"),
@@ -599,7 +740,6 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
{"vid": video_db_id, "rid": restaurant_id}, {"vid": video_db_id, "rid": restaurant_id},
) )
# Build new mapping values from body or old data
def _parse(val, default): def _parse(val, default):
if val is None: if val is None:
return default return default
@@ -630,14 +770,30 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
guests=guests if isinstance(guests, list) else [], guests=guests if isinstance(guests, list) else [],
) )
return {"ok": True, "remapped": True, "new_restaurant_id": new_rid} active_rid = new_rid
# No name change — update in place # 기존 식당이 다른 영상 매핑이 없으면 고아 → 삭제
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: with conn() as c:
cur = c.cursor() cur = c.cursor()
r_sets = [] r_sets = []
r_params: dict = {"rid": restaurant_id} r_params: dict = {"rid": active_rid}
for field in ("name", "address", "region", "cuisine_type", "price_range"): for field in ("address", "region", "cuisine_type", "price_range"):
if field in body: if field in body:
r_sets.append(f"{field} = :{field}") r_sets.append(f"{field} = :{field}")
r_params[field] = body[field] r_params[field] = body[field]
@@ -646,7 +802,7 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid" sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid"
cur.execute(sql, r_params) cur.execute(sql, r_params)
vr_params: dict = {"vid": video_db_id, "rid": restaurant_id} vr_params: dict = {"vid": video_db_id, "rid": active_rid}
vr_sets = [] vr_sets = []
for field in ("foods_mentioned", "evaluation", "guests"): for field in ("foods_mentioned", "evaluation", "guests"):
if field in body: if field in body:
@@ -657,4 +813,10 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid" sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid"
cur.execute(sql, vr_params) cur.execute(sql, vr_params)
return {"ok": True} 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
View 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
View 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 = ("한식|", "일식|", "중식|", "양식|", "아시아|", "기타|")

View File

@@ -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]:

View File

@@ -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)
# 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) 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,

View File

@@ -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],

View File

@@ -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">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">Tasteby Admin</h1> <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">
&larr; &larr;
</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>
);
}

View File

@@ -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,13 +422,13 @@ 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">
{/* Row 1: Search + dropdown filters */}
<div className="flex items-center gap-2">
<div className="w-96 shrink-0">
<SearchBar onSearch={handleSearch} isLoading={loading} /> <SearchBar onSearch={handleSearch} isLoading={loading} />
</div> </div>
{/* Desktop: filters inline */}
<div className="hidden md:flex items-center gap-2">
<select <select
value={channelFilter} value={channelFilter}
onChange={(e) => { onChange={(e) => {
@@ -163,7 +436,7 @@ export default function Home() {
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) => (
@@ -172,11 +445,86 @@ export default function Home() {
</option> </option>
))} ))}
</select> </select>
<select
value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)}
className="border rounded px-2 py-1 text-sm text-gray-600"
>
<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-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"
>
<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-sm text-gray-600"
>
<option value=""> /</option>
{districts.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
)}
</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 && ( {user && (
<> <>
<button <button
onClick={handleToggleFavorites} onClick={handleToggleFavorites}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${ className={`px-3 py-1 text-sm rounded-full border transition-colors ${
showFavorites showFavorites
? "bg-red-50 border-red-300 text-red-600" ? "bg-red-50 border-red-300 text-red-600"
: "border-gray-300 text-gray-600 hover:bg-gray-100" : "border-gray-300 text-gray-600 hover:bg-gray-100"
@@ -186,7 +534,7 @@ export default function Home() {
</button> </button>
<button <button
onClick={handleToggleMyReviews} onClick={handleToggleMyReviews}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${ className={`px-3 py-1 text-sm rounded-full border transition-colors ${
showMyReviews showMyReviews
? "bg-blue-50 border-blue-300 text-blue-600" ? "bg-blue-50 border-blue-300 text-blue-600"
: "border-gray-300 text-gray-600 hover:bg-gray-100" : "border-gray-300 text-gray-600 hover:bg-gray-100"
@@ -197,14 +545,13 @@ export default function Home() {
</> </>
)} )}
<span className="text-sm text-gray-500 whitespace-nowrap"> <span className="text-sm text-gray-500 whitespace-nowrap">
{restaurants.length} {filteredRestaurants.length}
</span> </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,10 +589,47 @@ 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">
<div className="flex items-center gap-1.5">
<div className="flex-1">
<SearchBar onSearch={handleSearch} isLoading={loading} /> <SearchBar onSearch={handleSearch} isLoading={loading} />
<div className="flex items-center gap-2 overflow-x-auto"> </div>
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
className={`px-2 py-1.5 text-xs border rounded transition-colors shrink-0 ${
viewMode === "map"
? "bg-blue-50 border-blue-300 text-blue-600"
: "text-gray-600"
}`}
>
{viewMode === "map" ? "🗺" : "☰"}
</button>
<button
onClick={() => setShowMobileFilters(!showMobileFilters)}
className={`px-2 py-1.5 text-xs border rounded transition-colors shrink-0 relative ${
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn
? "bg-blue-50 border-blue-300 text-blue-600"
: "text-gray-600"
}`}
>
{showMobileFilters ? "✕" : "▽"}
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn) && (
<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">
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
</span>
)}
</button>
<span className="text-xs text-gray-400 shrink-0">
{filteredRestaurants.length}
</span>
</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 <select
value={channelFilter} value={channelFilter}
onChange={(e) => { onChange={(e) => {
@@ -253,7 +637,7 @@ export default function Home() {
setSelected(null); setSelected(null);
setShowDetail(false); setShowDetail(false);
}} }}
className="border rounded px-2 py-1 text-xs text-gray-600 shrink-0" className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
> >
<option value=""> </option> <option value=""> </option>
{channels.map((ch) => ( {channels.map((ch) => (
@@ -262,49 +646,122 @@ export default function Home() {
</option> </option>
))} ))}
</select> </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 && ( {user && (
<> <>
<button <button
onClick={handleToggleFavorites} onClick={handleToggleFavorites}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${ className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
showFavorites showFavorites
? "bg-red-50 border-red-300 text-red-600" ? "bg-red-50 border-red-300 text-red-600"
: "border-gray-300 text-gray-600" : "border-gray-300 text-gray-600 bg-white"
}`} }`}
> >
{showFavorites ? "♥ 내 찜" : "♡ 찜"} {showFavorites ? "♥ 내 찜" : "♡ 찜"}
</button> </button>
<button <button
onClick={handleToggleMyReviews} onClick={handleToggleMyReviews}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${ className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
showMyReviews showMyReviews
? "bg-blue-50 border-blue-300 text-blue-600" ? "bg-blue-50 border-blue-300 text-blue-600"
: "border-gray-300 text-gray-600" : "border-gray-300 text-gray-600 bg-white"
}`} }`}
> >
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"} {showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
</button> </button>
</> </>
)} )}
<span className="text-xs text-gray-400 shrink-0 ml-1">
{restaurants.length}
</span>
</div> </div>
</div> </div>
)}
</div>
</header> </header>
{/* ── Body: Desktop = side-by-side, Mobile = stacked ── */} {/* ── Body: Desktop = side-by-side, Mobile = stacked ── */}
{/* Desktop layout */} {/* Desktop layout */}
<div className="hidden md:flex flex-1 overflow-hidden"> <div className="hidden md:flex flex-1 overflow-hidden">
{viewMode === "map" ? (
<>
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0"> <aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
{sidebarContent} {sidebarContent}
</aside> </aside>
<main className="flex-1 relative"> <main className="flex-1 relative">
<MapView <MapView
restaurants={restaurants} restaurants={filteredRestaurants}
selected={selected} selected={selected}
onSelectRestaurant={handleSelectRestaurant} onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
/> />
{visits && ( {visits && (
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded"> <div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
@@ -312,16 +769,41 @@ export default function Home() {
</div> </div>
)} )}
</main> </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"> <>
<div className="flex-1 relative">
<MapView <MapView
restaurants={restaurants} restaurants={filteredRestaurants}
selected={selected} selected={selected}
onSelectRestaurant={handleSelectRestaurant} onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
/> />
{visits && ( {visits && (
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10"> <div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
@@ -329,11 +811,41 @@ export default function Home() {
</div> </div>
)} )}
</div> </div>
{/* List/Detail: scrollable below */} </>
<div className="flex-1 bg-white border-t overflow-y-auto"> ) : (
{sidebarContent} <>
<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> </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} />
)}
</BottomSheet>
</div>
</div> </div>
); );
} }

View 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>
</>
);
}

View File

@@ -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 && (

View File

@@ -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>}

View File

@@ -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" }
);
},
}; };

View 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;
}

View File

@@ -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;
} }
} }