5 Commits

Author SHA1 Message Date
joungmin
f54da90b5f Add UX/design improvement guide document
Tracks planned UI/UX improvements with priorities and completion status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:10:03 +09:00
joungmin
4d09be2419 Add skeleton loading UI for better perceived performance
Replace "로딩 중..." text with animated skeleton placeholders in
RestaurantList, RestaurantDetail, and ReviewSection components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:08:03 +09:00
joungmin
6c47d3c57d Backend enhancements: auth, channels, restaurants, daemon improvements
- Add admin auth dependency and role checks
- Expand channel and restaurant API routes
- Improve YouTube transcript fetching
- Enhance daemon worker with better error handling and scheduling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:59:22 +09:00
joungmin
d6afb62c18 Mobile header: split search and toolbar into two rows
- Row 1: search bar (full width)
- Row 2: map/list toggle, filter toggle with badge, favorites, reviews, count
- Moved favorites/reviews buttons out of collapsible filter panel to toolbar row

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:56:19 +09:00
joungmin
2bddb0f764 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>
2026-03-09 10:54:28 +09:00
29 changed files with 2659 additions and 333 deletions

View File

@@ -30,3 +30,11 @@ def get_optional_user(authorization: str = Header(None)) -> dict | None:
return verify_jwt(token) return verify_jwt(token)
except Exception: except Exception:
return None return None
def get_admin_user(authorization: str = Header(None)) -> dict:
"""Require authenticated admin user. Raises 401/403."""
user = get_current_user(authorization)
if not user.get("is_admin"):
raise HTTPException(403, "관리자 권한이 필요합니다")
return user

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api.routes import restaurants, channels, videos, search, auth, reviews, admin_users, stats from api.routes import restaurants, channels, videos, search, auth, reviews, admin_users, stats, daemon
app = FastAPI( app = FastAPI(
title="Tasteby API", title="Tasteby API",
@@ -29,6 +29,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(reviews.router, prefix="/api", tags=["reviews"]) app.include_router(reviews.router, prefix="/api", tags=["reviews"])
app.include_router(admin_users.router, prefix="/api/admin/users", tags=["admin-users"]) app.include_router(admin_users.router, prefix="/api/admin/users", tags=["admin-users"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(daemon.router, prefix="/api/daemon", tags=["daemon"])
@app.get("/api/health") @app.get("/api/health")

View File

@@ -36,5 +36,22 @@ def login_google(body: GoogleLoginRequest):
@router.get("/me") @router.get("/me")
def get_me(current_user: dict = Depends(get_current_user)): def get_me(current_user: dict = Depends(get_current_user)):
"""Return current authenticated user info.""" """Return current authenticated user info including admin status."""
return current_user from core.db import conn
user_id = current_user.get("sub") or current_user.get("id")
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT id, email, nickname, avatar_url, is_admin FROM tasteby_users WHERE id = :id",
{"id": user_id},
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "User not found")
return {
"id": row[0],
"email": row[1],
"nickname": row[2],
"avatar_url": row[3],
"is_admin": bool(row[4]),
}

View File

@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio import asyncio
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from core import youtube from api.deps import get_admin_user
from core import youtube, cache
_executor = ThreadPoolExecutor(max_workers=4) _executor = ThreadPoolExecutor(max_workers=4)
@@ -23,13 +25,20 @@ class ChannelCreate(BaseModel):
@router.get("") @router.get("")
def list_channels(): def list_channels():
return youtube.get_active_channels() key = cache.make_key("channels")
cached = cache.get(key)
if cached is not None:
return cached
result = youtube.get_active_channels()
cache.set(key, result)
return result
@router.post("", status_code=201) @router.post("", status_code=201)
def create_channel(body: ChannelCreate): def create_channel(body: ChannelCreate, _admin: dict = Depends(get_admin_user)):
try: try:
row_id = youtube.add_channel(body.channel_id, body.channel_name, body.title_filter) row_id = youtube.add_channel(body.channel_id, body.channel_name, body.title_filter)
cache.flush()
return {"id": row_id, "channel_id": body.channel_id} return {"id": row_id, "channel_id": body.channel_id}
except Exception as e: except Exception as e:
if "UQ_CHANNELS_CID" in str(e).upper(): if "UQ_CHANNELS_CID" in str(e).upper():
@@ -63,12 +72,15 @@ def _do_scan(channel_id: str, full: bool):
if not full and new_in_page == 0 and total_fetched > 50: if not full and new_in_page == 0 and total_fetched > 50:
break break
filtered = total_fetched - len(candidates) - len([v for v in candidates if v["video_id"] in existing_vids])
new_count = youtube.save_videos_batch(ch["id"], candidates) new_count = youtube.save_videos_batch(ch["id"], candidates)
return {"total_fetched": total_fetched, "new_videos": new_count} if new_count > 0:
cache.flush()
return {"total_fetched": total_fetched, "new_videos": new_count, "filtered": filtered if title_filter else 0}
@router.post("/{channel_id}/scan") @router.post("/{channel_id}/scan")
async def scan_channel(channel_id: str, full: bool = False): async def scan_channel(channel_id: str, full: bool = False, _admin: dict = Depends(get_admin_user)):
"""Trigger a scan for new videos from this channel (non-blocking).""" """Trigger a scan for new videos from this channel (non-blocking)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor(_executor, _do_scan, channel_id, full) result = await loop.run_in_executor(_executor, _do_scan, channel_id, full)
@@ -78,7 +90,7 @@ async def scan_channel(channel_id: str, full: bool = False):
@router.delete("/{channel_id:path}") @router.delete("/{channel_id:path}")
def delete_channel(channel_id: str): def delete_channel(channel_id: str, _admin: dict = Depends(get_admin_user)):
"""Deactivate a channel. Accepts channel_id or DB id.""" """Deactivate a channel. Accepts channel_id or DB id."""
deleted = youtube.deactivate_channel(channel_id) deleted = youtube.deactivate_channel(channel_id)
if not deleted: if not deleted:
@@ -86,4 +98,5 @@ def delete_channel(channel_id: str):
deleted = youtube.deactivate_channel_by_db_id(channel_id) deleted = youtube.deactivate_channel_by_db_id(channel_id)
if not deleted: if not deleted:
raise HTTPException(404, "Channel not found") raise HTTPException(404, "Channel not found")
cache.flush()
return {"ok": True} return {"ok": True}

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

@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from core import restaurant from api.deps import get_admin_user
from core import restaurant, cache
router = APIRouter() router = APIRouter()
@@ -17,19 +18,30 @@ def list_restaurants(
region: str | None = None, region: str | None = None,
channel: str | None = None, channel: str | None = None,
): ):
return restaurant.get_all(limit=limit, offset=offset, cuisine=cuisine, region=region, channel=channel) key = cache.make_key("restaurants", f"l={limit}", f"o={offset}", f"c={cuisine}", f"r={region}", f"ch={channel}")
cached = cache.get(key)
if cached is not None:
return cached
result = restaurant.get_all(limit=limit, offset=offset, cuisine=cuisine, region=region, channel=channel)
cache.set(key, result)
return result
@router.get("/{restaurant_id}") @router.get("/{restaurant_id}")
def get_restaurant(restaurant_id: str): def get_restaurant(restaurant_id: str):
key = cache.make_key("restaurant", restaurant_id)
cached = cache.get(key)
if cached is not None:
return cached
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
raise HTTPException(404, "Restaurant not found") raise HTTPException(404, "Restaurant not found")
cache.set(key, r)
return r return r
@router.put("/{restaurant_id}") @router.put("/{restaurant_id}")
def update_restaurant(restaurant_id: str, body: dict): def update_restaurant(restaurant_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
from core.db import conn from core.db import conn
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
@@ -49,11 +61,12 @@ def update_restaurant(restaurant_id: str, body: dict):
sql = f"UPDATE restaurants SET {', '.join(sets)} WHERE id = :rid" sql = f"UPDATE restaurants SET {', '.join(sets)} WHERE id = :rid"
with conn() as c: with conn() as c:
c.cursor().execute(sql, params) c.cursor().execute(sql, params)
cache.flush()
return {"ok": True} return {"ok": True}
@router.delete("/{restaurant_id}") @router.delete("/{restaurant_id}")
def delete_restaurant(restaurant_id: str): def delete_restaurant(restaurant_id: str, _admin: dict = Depends(get_admin_user)):
from core.db import conn from core.db import conn
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
@@ -64,12 +77,19 @@ def delete_restaurant(restaurant_id: str):
cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id}) cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM video_restaurants WHERE restaurant_id = :rid", {"rid": restaurant_id}) cur.execute("DELETE FROM video_restaurants WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id}) cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id})
cache.flush()
return {"ok": True} return {"ok": True}
@router.get("/{restaurant_id}/videos") @router.get("/{restaurant_id}/videos")
def get_restaurant_videos(restaurant_id: str): def get_restaurant_videos(restaurant_id: str):
key = cache.make_key("restaurant_videos", restaurant_id)
cached = cache.get(key)
if cached is not None:
return cached
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
raise HTTPException(404, "Restaurant not found") raise HTTPException(404, "Restaurant not found")
return restaurant.get_video_links(restaurant_id) result = restaurant.get_video_links(restaurant_id)
cache.set(key, result)
return result

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

View File

@@ -67,6 +67,9 @@ def find_or_create_user(
"email": email, "nickname": nickname, "email": email, "nickname": nickname,
"avatar_url": avatar_url, "id": row[0], "avatar_url": avatar_url, "id": row[0],
}) })
# Fetch is_admin
cur.execute("SELECT is_admin FROM tasteby_users WHERE id = :id", {"id": row[0]})
is_admin = bool(cur.fetchone()[0])
return { return {
"id": row[0], "id": row[0],
"provider": row[1], "provider": row[1],
@@ -74,6 +77,7 @@ def find_or_create_user(
"email": email or row[3], "email": email or row[3],
"nickname": nickname or row[4], "nickname": nickname or row[4],
"avatar_url": avatar_url or row[5], "avatar_url": avatar_url or row[5],
"is_admin": is_admin,
} }
# Create new user # Create new user
@@ -99,6 +103,7 @@ def find_or_create_user(
"email": email, "email": email,
"nickname": nickname, "nickname": nickname,
"avatar_url": avatar_url, "avatar_url": avatar_url,
"is_admin": False,
} }
@@ -108,6 +113,7 @@ def create_jwt(user: dict) -> str:
"sub": user["id"], "sub": user["id"],
"email": user.get("email"), "email": user.get("email"),
"nickname": user.get("nickname"), "nickname": user.get("nickname"),
"is_admin": user.get("is_admin", False),
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRE_DAYS), "exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRE_DAYS),
"iat": datetime.now(timezone.utc), "iat": datetime.now(timezone.utc),
} }

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

@@ -72,12 +72,22 @@ def deactivate_channel_by_db_id(db_id: str) -> bool:
def get_active_channels() -> list[dict]: def get_active_channels() -> list[dict]:
sql = "SELECT id, channel_id, channel_name, title_filter FROM channels WHERE is_active = 1" sql = """
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) as video_count,
(SELECT MAX(v.created_at) FROM videos v WHERE v.channel_id = c.id) as last_scanned_at
FROM channels c
WHERE c.is_active = 1
"""
with conn() as c: with conn() as c:
cur = c.cursor() cur = c.cursor()
cur.execute(sql) cur.execute(sql)
return [ return [
{"id": r[0], "channel_id": r[1], "channel_name": r[2], "title_filter": r[3]} {
"id": r[0], "channel_id": r[1], "channel_name": r[2], "title_filter": r[3],
"video_count": r[4] or 0,
"last_scanned_at": r[5].isoformat() if r[5] else None,
}
for r in cur.fetchall() for r in cur.fetchall()
] ]
@@ -99,13 +109,48 @@ def get_latest_video_date(channel_db_id: str) -> str | None:
return None return None
def _parse_iso8601_duration(dur: str) -> int:
"""Parse ISO 8601 duration (e.g. PT1M30S, PT5M, PT1H2M) to seconds."""
import re
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", dur or "")
if not m:
return 0
h, mn, s = (int(x) if x else 0 for x in m.groups())
return h * 3600 + mn * 60 + s
def _filter_shorts(videos: list[dict]) -> list[dict]:
"""Filter out YouTube Shorts (<=60s) by checking video durations via API."""
if not videos:
return videos
video_ids = [v["video_id"] for v in videos]
r = httpx.get(
"https://www.googleapis.com/youtube/v3/videos",
params={
"key": _api_key(),
"id": ",".join(video_ids),
"part": "contentDetails",
},
timeout=30,
)
r.raise_for_status()
durations = {}
for item in r.json().get("items", []):
durations[item["id"]] = _parse_iso8601_duration(
item.get("contentDetails", {}).get("duration", "")
)
return [v for v in videos if durations.get(v["video_id"], 0) > 60]
def fetch_channel_videos_iter( def fetch_channel_videos_iter(
channel_id: str, channel_id: str,
published_after: str | None = None, published_after: str | None = None,
exclude_shorts: bool = True,
): ):
"""Yield pages of videos from a YouTube channel via Data API v3. """Yield pages of videos from a YouTube channel via Data API v3.
Each yield is a list of dicts for one API page (up to 50). Each yield is a list of dicts for one API page (up to 50).
If exclude_shorts is True, filters out videos <= 60 seconds.
""" """
params: dict = { params: dict = {
"key": _api_key(), "key": _api_key(),
@@ -127,7 +172,7 @@ def fetch_channel_videos_iter(
r = httpx.get( r = httpx.get(
"https://www.googleapis.com/youtube/v3/search", "https://www.googleapis.com/youtube/v3/search",
params=params, params=params,
timeout=15, timeout=30,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@@ -143,6 +188,9 @@ def fetch_channel_videos_iter(
"url": f"https://www.youtube.com/watch?v={vid}", "url": f"https://www.youtube.com/watch?v={vid}",
}) })
if page_videos and exclude_shorts:
page_videos = _filter_shorts(page_videos)
if page_videos: if page_videos:
yield page_videos yield page_videos

View File

@@ -1,37 +1,92 @@
"""Daemon worker: periodic channel scan + video processing.""" """Daemon worker: config-driven channel scan + video processing."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import time import time
from datetime import datetime, timedelta
from core.db import conn
from core.youtube import scan_all_channels from core.youtube import scan_all_channels
from core.pipeline import process_pending from core.pipeline import process_pending
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CHECK_INTERVAL = 30 # seconds between config checks
def run_once() -> None:
"""Single daemon cycle: scan channels then process pending videos.""" def _get_config() -> dict | None:
logger.info("=== Daemon cycle start ===") """Read daemon config from DB."""
try:
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 "
"FROM daemon_config WHERE id = 1"
)
row = cur.fetchone()
if not row:
return None
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": row[5],
"last_process_at": row[6],
}
except Exception as e:
logger.error("Failed to read daemon config: %s", e)
return None
def _should_run(last_at: datetime | None, interval_min: int) -> bool:
"""Check if enough time has passed since last run."""
if last_at is None:
return True
now = datetime.utcnow()
# Oracle TIMESTAMP comes as datetime
return now - last_at >= timedelta(minutes=interval_min)
def _update_last(field: str) -> None:
"""Update last_scan_at or last_process_at."""
with conn() as c:
c.cursor().execute(
f"UPDATE daemon_config SET {field} = SYSTIMESTAMP WHERE id = 1"
)
def run_once_if_due() -> None:
"""Check config and run tasks if their schedule is due."""
cfg = _get_config()
if not cfg:
return
if cfg["scan_enabled"] and _should_run(cfg["last_scan_at"], cfg["scan_interval_min"]):
logger.info("=== Scheduled scan start ===")
try: try:
new_count = scan_all_channels() new_count = scan_all_channels()
logger.info("Scan complete: %d new videos", new_count) logger.info("Scan complete: %d new videos", new_count)
_update_last("last_scan_at")
except Exception as e: except Exception as e:
logger.error("Channel scan failed: %s", e) logger.error("Channel scan failed: %s", e)
if cfg["process_enabled"] and _should_run(cfg["last_process_at"], cfg["process_interval_min"]):
logger.info("=== Scheduled processing start ===")
try: try:
rest_count = process_pending(limit=10) rest_count = process_pending(limit=cfg["process_limit"])
logger.info("Processing complete: %d restaurants extracted", rest_count) logger.info("Processing complete: %d restaurants extracted", rest_count)
_update_last("last_process_at")
except Exception as e: except Exception as e:
logger.error("Video processing failed: %s", e) logger.error("Video processing failed: %s", e)
logger.info("=== Daemon cycle end ===")
def run_loop() -> None:
def run_loop(interval: int = 3600) -> None: """Run daemon loop, checking config every CHECK_INTERVAL seconds."""
"""Run daemon in a loop with configurable interval (default 1 hour).""" logger.info("Daemon started (config-driven, check every %ds)", CHECK_INTERVAL)
logger.info("Daemon started (interval=%ds)", interval)
while True: while True:
run_once() run_once_if_due()
time.sleep(interval) time.sleep(CHECK_INTERVAL)

View File

@@ -1,7 +1,6 @@
"""Run the daemon worker.""" """Run the daemon worker."""
import logging import logging
import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -14,5 +13,4 @@ logging.basicConfig(
) )
if __name__ == "__main__": if __name__ == "__main__":
interval = int(os.environ.get("DAEMON_INTERVAL", "3600")) run_loop()
run_loop(interval)

74
docs/ux-design-guide.md Normal file
View File

@@ -0,0 +1,74 @@
# Tasteby UX/디자인 개선 가이드
## 1. 네비게이션 & 구조
| 원칙 | 설명 | 적용 아이디어 |
|---|---|---|
| **Bottom Navigation** | 모바일에서 엄지 닿는 하단에 핵심 탭 (지도/검색/찜/마이) | 현재 상단에 모든 것이 몰려있음 → 하단 탭바 추가 |
| **Sticky 검색바** | 스크롤해도 검색은 항상 접근 가능 | 리스트 모드에서 검색바가 스크롤에 묻힘 |
| **Progressive Disclosure** | 필터는 처음엔 숨기고, 필요할 때 펼치기 | 필터 아이콘 터치 시 바텀시트로 |
| **2탭 이내 도달** | 핵심 기능까지 최대 2번 터치 | 식당 상세 → 마커 탭 → 바로 상세 (현재 OK) |
## 2. 비주얼 & 레이아웃
| 원칙 | 설명 | 적용 아이디어 |
|---|---|---|
| **카드 UI** | 식당 정보를 그림자+라운드 카드로 구분 | 리스트 아이템을 카드 형태로 |
| **음식 사진** | 텍스트보다 사진이 10배 효과 | Google Places 사진 API로 대표 이미지 추가 |
| **여백(White Space)** | 요소 간 충분한 간격 → 시각적 호흡 | 현재 필터/버튼이 빽빽 → 간격 확보 |
| **일관된 색상 체계** | 브랜드 컬러 1개 + 보조 1-2개 | 현재 blue/red/teal/amber 혼재 → 통일 |
| **아이콘 + 텍스트** | 아이콘만은 의미 불명확, 텍스트만은 지루 | 필터 버튼에 아이콘+라벨 조합 |
## 3. 인터랙션 & 피드백
| 원칙 | 설명 | 적용 아이디어 |
|---|---|---|
| **마이크로 애니메이션** | 탭 전환, 카드 등장 시 부드러운 전환 | 리스트↔지도 전환에 fade/slide |
| **Skeleton Loading** | 로딩 중 회색 뼈대 표시 (스피너 대신) | 식당 목록 로딩 시 스켈레톤 |
| **Pull to Refresh** | 모바일에서 당겨서 새로고침 | 리스트 모드에서 |
| **Toast 알림** | 액션 완료 시 하단에 잠깐 표시 | "찜 완료", "리뷰 저장됨" 등 |
## 4. 모바일 특화
| 원칙 | 설명 | 적용 아이디어 |
|---|---|---|
| **Bottom Sheet** | 지도 위 하단에서 올라오는 패널 (Google Maps 스타일) | 식당 상세를 바텀시트로 |
| **Swipe 제스처** | 좌우 스와이프로 식당 넘기기 | 상세 화면에서 이전/다음 식당 |
| **한 손 조작 영역** | 핵심 버튼은 하단 60% 안에 | 현재 필터가 최상단 → 접근 어려움 |
| **반응형 지도 크기** | 리스트 모드: 지도 작게 / 지도 모드: 전체 | 현재 고정 비율 → 동적 조절 |
## 5. 개인화 & 스마트 기능
| 원칙 | 설명 | 적용 아이디어 |
|---|---|---|
| **최근 본 식당** | 히스토리 자동 저장 | 로컬스토리지로 구현 가능 |
| **맞춤 추천** | "당신이 좋아할 식당" | 찜/리뷰 기반 유사 식당 추천 |
| **위치 기반 기본값** | 현재 위치 근처 식당 우선 | 이미 구현 (geolocation) |
| **검색 자동완성** | 타이핑 중 식당/메뉴 서제스트 | 검색바에 debounced suggestions |
## 6. 디자인 트렌드 (2025-2026)
| 트렌드 | 설명 |
|---|---|
| **Bento Box 레이아웃** | 대시보드형 격자 카드 배치 (Apple 스타일) |
| **Glassmorphism** | 반투명 유리 효과 배경 (지도 위 오버레이에 적합) |
| **다크모드** | 시스템 설정 연동 자동 전환 |
| **대담한 타이포그래피** | 큰 글씨 + 굵은 폰트로 핵심 정보 강조 |
| **Scroll-telling** | 스크롤 따라 콘텐츠가 자연스럽게 전개 |
## 우선 적용 추천 순위
1. ~~**모바일 Bottom Sheet** — 지도 위 식당 상세를 Google Maps처럼~~ ✅ 완료
- `BottomSheet.tsx` 컴포넌트: 3단 snap (40%/55%/92%), 드래그 제스처, 속도 감지
- 모바일 지도 모드: 전체 화면 지도 + 바텀시트로 상세
- 모바일 리스트 모드: 리스트 유지 + 바텀시트로 상세
2. **식당 카드 + 음식 사진** — 시각적 임팩트 (Google Places Photo API)
3. **필터를 바텀시트/드롭다운으로** — 상단 필터 줄 정리
4. ~~**Skeleton Loading** — 체감 속도 향상~~ ✅ 완료
- `Skeleton.tsx`: RestaurantListSkeleton (8개 카드), RestaurantDetailSkeleton
- RestaurantList: loading prop으로 스켈레톤 표시
- RestaurantDetail: 비디오 로딩 시 스켈레톤
- ReviewSection: 리뷰 로딩 시 스켈레톤
- 초기 로드 + 검색 + 채널 변경 시 모두 스켈레톤 표시
5. **Toast 알림** — 액션 피드백
6. **다크모드** — 트렌드 + 야간 사용성

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,28 +1,195 @@
"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();
const [restaurants, setRestaurants] = useState<Restaurant[]>([]); const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
const [selected, setSelected] = useState<Restaurant | null>(null); const [selected, setSelected] = useState<Restaurant | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
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(() => {
@@ -32,12 +199,38 @@ export default function Home() {
// Load restaurants on mount and when channel filter changes // Load restaurants on mount and when channel filter changes
useEffect(() => { useEffect(() => {
setLoading(true);
api api
.getRestaurants({ limit: 200, channel: channelFilter || undefined }) .getRestaurants({ limit: 500, channel: channelFilter || undefined })
.then(setRestaurants) .then(setRestaurants)
.catch(console.error); .catch(console.error)
.finally(() => setLoading(false));
}, [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 +257,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 +335,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 +366,7 @@ export default function Home() {
} }
}; };
// Desktop sidebar: shows detail inline
const sidebarContent = showMyReviews ? ( const sidebarContent = showMyReviews ? (
<MyReviewsList <MyReviewsList
reviews={myReviews} reviews={myReviews}
@@ -134,9 +387,33 @@ export default function Home() {
/> />
) : ( ) : (
<RestaurantList <RestaurantList
restaurants={restaurants} restaurants={filteredRestaurants}
selectedId={selected?.id} selectedId={selected?.id}
onSelect={handleSelectRestaurant} onSelect={handleSelectRestaurant}
loading={loading}
/>
);
// 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}
onSelect={handleSelectRestaurant}
loading={loading}
/> />
); );
@@ -149,13 +426,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 +440,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 +449,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 +538,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 +549,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 +593,71 @@ 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">
{/* Row 1: Search */}
<SearchBar onSearch={handleSearch} isLoading={loading} /> <SearchBar onSearch={handleSearch} isLoading={loading} />
<div className="flex items-center gap-2 overflow-x-auto"> {/* Row 2: Toolbar */}
<div className="flex items-center gap-1.5">
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
className={`px-2.5 py-1 text-xs border rounded transition-colors ${
viewMode === "map"
? "bg-blue-50 border-blue-300 text-blue-600"
: "text-gray-600"
}`}
>
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
</button>
<button
onClick={() => setShowMobileFilters(!showMobileFilters)}
className={`px-2.5 py-1 text-xs border rounded transition-colors 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.5 -right-1.5 w-4 h-4 bg-blue-500 text-white rounded-full text-[9px] flex items-center justify-center">
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
</span>
)}
</button>
{user && (
<>
<button
onClick={handleToggleFavorites}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
showFavorites
? "bg-red-50 border-red-300 text-red-600"
: "border-gray-300 text-gray-600"
}`}
>
{showFavorites ? "♥ 찜" : "♡ 찜"}
</button>
<button
onClick={handleToggleMyReviews}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
showMyReviews
? "bg-blue-50 border-blue-300 text-blue-600"
: "border-gray-300 text-gray-600"
}`}
>
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
</button>
</>
)}
<span className="text-xs text-gray-400 ml-auto">
{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 +665,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,34 +674,79 @@ export default function Home() {
</option> </option>
))} ))}
</select> </select>
{user && ( <select
<> value={cuisineFilter}
<button onChange={(e) => setCuisineFilter(e.target.value)}
onClick={handleToggleFavorites} className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
showFavorites
? "bg-red-50 border-red-300 text-red-600"
: "border-gray-300 text-gray-600"
}`}
> >
{showFavorites ? "♥ 내 찜" : "♡ 찜"} <option value=""> </option>
</button> {CUISINE_GROUPS.map((g) => (
<button <option key={g.label} value={g.label}>{g.label}</option>
onClick={handleToggleMyReviews} ))}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${ </select>
showMyReviews <select
? "bg-blue-50 border-blue-300 text-blue-600" value={priceFilter}
: "border-gray-300 text-gray-600" onChange={(e) => setPriceFilter(e.target.value)}
}`} className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
> >
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"} <option value=""> </option>
</button> {PRICE_GROUPS.map((g) => (
</> <option key={g.label} value={g.label}>{g.label}</option>
)} ))}
<span className="text-xs text-gray-400 shrink-0 ml-1"> </select>
{restaurants.length}
</span>
</div> </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>
</div>
</div>
)}
</div> </div>
</header> </header>
@@ -297,14 +754,18 @@ export default function Home() {
{/* Desktop layout */} {/* Desktop layout */}
<div className="hidden md:flex flex-1 overflow-hidden"> <div className="hidden md:flex flex-1 overflow-hidden">
{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 +773,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 +815,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

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { api, getToken } from "@/lib/api"; import { api, getToken } from "@/lib/api";
import type { Restaurant, VideoLink } from "@/lib/api"; import type { Restaurant, VideoLink } from "@/lib/api";
import ReviewSection from "@/components/ReviewSection"; import ReviewSection from "@/components/ReviewSection";
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
interface RestaurantDetailProps { interface RestaurantDetailProps {
restaurant: Restaurant; restaurant: Restaurant;
@@ -137,7 +138,21 @@ export default function RestaurantDetail({
<div> <div>
<h3 className="font-semibold text-sm mb-2"> </h3> <h3 className="font-semibold text-sm mb-2"> </h3>
{loading ? ( {loading ? (
<p className="text-sm text-gray-500"> ...</p> <div className="space-y-3 animate-pulse">
{[1, 2].map((i) => (
<div key={i} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<div className="h-4 w-16 bg-gray-200 rounded-sm" />
<div className="h-3 w-20 bg-gray-200 rounded" />
</div>
<div className="h-4 w-full bg-gray-200 rounded" />
<div className="flex gap-1">
<div className="h-5 w-14 bg-gray-200 rounded" />
<div className="h-5 w-16 bg-gray-200 rounded" />
</div>
</div>
))}
</div>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
<p className="text-sm text-gray-500"> </p> <p className="text-sm text-gray-500"> </p>
) : ( ) : (

View File

@@ -1,18 +1,26 @@
"use client"; "use client";
import type { Restaurant } from "@/lib/api"; import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import { RestaurantListSkeleton } from "@/components/Skeleton";
interface RestaurantListProps { interface RestaurantListProps {
restaurants: Restaurant[]; restaurants: Restaurant[];
selectedId?: string; selectedId?: string;
onSelect: (r: Restaurant) => void; onSelect: (r: Restaurant) => void;
loading?: boolean;
} }
export default function RestaurantList({ export default function RestaurantList({
restaurants, restaurants,
selectedId, selectedId,
onSelect, onSelect,
loading,
}: RestaurantListProps) { }: RestaurantListProps) {
if (loading) {
return <RestaurantListSkeleton />;
}
if (!restaurants.length) { if (!restaurants.length) {
return ( return (
<div className="p-4 text-center text-gray-500 text-sm"> <div className="p-4 text-center text-gray-500 text-sm">
@@ -31,7 +39,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

@@ -200,7 +200,18 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<h3 className="font-semibold text-sm mb-2"></h3> <h3 className="font-semibold text-sm mb-2"></h3>
{loading ? ( {loading ? (
<p className="text-sm text-gray-500"> ...</p> <div className="space-y-3 animate-pulse">
<div className="flex items-center gap-2">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-4 w-8 bg-gray-200 rounded" />
</div>
{[1, 2].map((i) => (
<div key={i} className="space-y-1">
<div className="h-3 w-20 bg-gray-200 rounded" />
<div className="h-3 w-full bg-gray-200 rounded" />
</div>
))}
</div>
) : ( ) : (
<> <>
{reviewCount > 0 && avgRating !== null && ( {reviewCount > 0 && avgRating !== null && (

View File

@@ -0,0 +1,80 @@
"use client";
/** Pulsing skeleton block */
function Block({ className = "" }: { className?: string }) {
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />;
}
/** Skeleton for a single restaurant list item */
export function RestaurantCardSkeleton() {
return (
<div className="px-4 py-3 space-y-2">
<Block className="h-4 w-3/5" />
<div className="flex gap-2">
<Block className="h-3 w-16" />
<Block className="h-3 w-20" />
<Block className="h-3 w-14" />
</div>
<div className="flex gap-1">
<Block className="h-4 w-14 rounded-sm" />
</div>
</div>
);
}
/** Skeleton for the restaurant list (multiple cards) */
export function RestaurantListSkeleton({ count = 8 }: { count?: number }) {
return (
<div className="divide-y divide-gray-100">
{Array.from({ length: count }, (_, i) => (
<RestaurantCardSkeleton key={i} />
))}
</div>
);
}
/** Skeleton for restaurant detail view */
export function RestaurantDetailSkeleton() {
return (
<div className="p-4 space-y-4 animate-pulse">
{/* Title + close */}
<div className="flex justify-between items-start">
<Block className="h-6 w-40" />
<Block className="h-6 w-6 rounded" />
</div>
{/* Rating */}
<div className="flex items-center gap-2">
<Block className="h-4 w-24" />
<Block className="h-4 w-8" />
</div>
{/* Info lines */}
<div className="space-y-2">
<Block className="h-4 w-48" />
<Block className="h-4 w-64" />
<Block className="h-4 w-36" />
<Block className="h-4 w-28" />
</div>
{/* Videos section */}
<div className="space-y-3">
<Block className="h-4 w-20" />
{[1, 2].map((i) => (
<div key={i} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<Block className="h-4 w-16 rounded-sm" />
<Block className="h-3 w-20" />
</div>
<Block className="h-4 w-full" />
<div className="flex gap-1">
<Block className="h-5 w-14 rounded" />
<Block className="h-5 w-16 rounded" />
</div>
<Block className="h-3 w-3/4" />
</div>
))}
</div>
</div>
);
}

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